Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Sparkes
e5d8bfec0e Beta (#1138)
* 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

* feat: new default quotes experience, improve added page

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

* 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

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

* font: replace montserrat with inter

* cleanup: remove unused code from addons and marketplace

* fix(greeting/events): event text box styling

* fix(quote/buttons): improve state management and event handling

* feat(background): implement custom background loading and improve state management

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

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

* fix(background/custom): prevent flashing during uploads

* feat(storage): implement dynamic storage quota estimation and request persistence

* feat(modal): enhance close button styling and theming support

* fix(Custom): remove unnecessary characters from loading state

* feat(Dropdown): implement dropdown closing animation and portal rendering

* fix(QuoteOptions): ensure authorDetails is set to true for all users during migration

* refactor(Items): remove unused imports and hex color conversion logic

* fix: add blurhash dependency for image metadata encoding

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
Co-authored-by: David Ralph <me@davidcralph.co.uk>
2026-01-27 12:35:18 +00:00
Alex Sparkes
bc9cf3c11e 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
2026-01-25 17:46:15 +00:00
Alex Sparkes
896816c185 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
2026-01-25 17:27:54 +00:00
David Ralph
6d209e10fb cleanup: remove 50% of offline images 2026-01-25 17:00:09 +00:00
121 changed files with 6619 additions and 2645 deletions

6
.github/CODEOWNERS vendored
View File

@@ -1,2 +1,8 @@
# Automatically assigned to any PRs
* @davidcralph @alexsparkes
# Workflow files require maintainer approval
/.github/workflows/ @davidcralph @alexsparkes
# Release process documentation
/docs/RELEASE_PROCESS.md @davidcralph @alexsparkes

65
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,65 @@
## Description
<!-- Provide a brief description of your changes -->
## Type of Change
<!-- Mark the relevant option with an 'x' -->
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📝 Documentation update
- [ ] 🎨 UI/UX improvement
- [ ] ⚡ Performance improvement
- [ ] 🔧 Maintenance/refactoring
## Testing
<!-- Describe the tests you ran and how to reproduce them -->
- [ ] Tested on Chrome/Edge
- [ ] Tested on Firefox
- [ ] Tested on Safari (if applicable)
- [ ] Checked console for errors
- [ ] Tested in different screen sizes
## Checklist
### General
- [ ] My code follows the project's code style
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] My changes generate no new warnings or errors
- [ ] I have tested my changes locally
### For Feature/Bug Fix PRs
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the documentation accordingly
### For Release PRs (beta → main)
<!-- Only fill out if this is a release PR -->
- [ ] Version has been bumped in all necessary files
- [ ] Changelog has been updated
- [ ] Beta testing period completed (minimum X days)
- [ ] All critical bugs from beta have been resolved
- [ ] Extension has been tested by at least X beta testers
- [ ] No open P0/P1 issues blocking release
- [ ] Release notes prepared
- [ ] Store submission credentials verified
## Screenshots (if applicable)
<!-- Add screenshots to help explain your changes -->
## Related Issues
<!-- Link any related issues here -->
Closes #
Relates to #
## Additional Notes
<!-- Any additional information for reviewers -->

165
.github/workflows/beta-release.yml vendored Normal file
View File

@@ -0,0 +1,165 @@
name: Beta Release
on:
push:
branches:
- beta
tags:
- 'v*-beta.*'
permissions:
contents: write
jobs:
build-and-release:
runs-on: ubuntu-latest
environment: beta
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
- name: Install dependencies
run: bun install
- name: Build extension
run: bun run build
env:
NODE_ENV: production
- name: Get version from package.json
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- 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)
else
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
echo "### ✨ Features"
echo "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### 🐛 Bug Fixes"
echo "$FIXES"
echo ""
fi
if [ -n "$CHORES" ]; then
echo "### 🔧 Maintenance"
echo "$CHORES"
echo ""
fi
if [ -n "$OTHER" ]; then
echo "### 📝 Other Changes"
echo "$OTHER"
fi
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Check if release exists
id: check_release
run: |
if gh release view "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Create or Update GitHub Pre-Release
run: |
RELEASE_NOTES=$(cat <<EOF
## 🧪 Mue Beta 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" \
"build/firefox-${{ steps.version.outputs.version }}.zip" \
--clobber
else
echo "Creating new release..."
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 }}" \
--notes "$RELEASE_NOTES" \
--prerelease
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Output release info
run: |
echo "## 🎉 Beta Release Created!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Release URL**: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Build Artifacts" >> $GITHUB_STEP_SUMMARY
echo "- Chrome/Edge: \`chrome-${{ steps.version.outputs.version }}.zip\`" >> $GITHUB_STEP_SUMMARY
echo "- Firefox: \`firefox-${{ steps.version.outputs.version }}.zip\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🧪 Testing" >> $GITHUB_STEP_SUMMARY
echo "Share the release link with beta testers for feedback." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ⚠️ Next Steps" >> $GITHUB_STEP_SUMMARY
echo "1. Test the beta release thoroughly" >> $GITHUB_STEP_SUMMARY
echo "2. Gather feedback from testers" >> $GITHUB_STEP_SUMMARY
echo "3. Fix any critical issues" >> $GITHUB_STEP_SUMMARY
echo "4. When ready, create PR from \`beta\` → \`main\` for production release" >> $GITHUB_STEP_SUMMARY

194
.github/workflows/hotfix-release.yml vendored Normal file
View File

@@ -0,0 +1,194 @@
name: Hotfix Release
on:
workflow_dispatch:
inputs:
description:
description: 'Brief description of the hotfix'
required: true
branch_name:
description: 'Hotfix branch name (e.g., hotfix/critical-security-fix)'
required: true
permissions:
contents: write
pull-requests: write
jobs:
hotfix-release:
runs-on: ubuntu-latest
environment: production # Requires maintainer approval
steps:
- name: Validate branch name
run: |
if [[ ! "${{ github.event.inputs.branch_name }}" =~ ^hotfix/ ]]; then
echo "❌ Branch name must start with 'hotfix/'" >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Checkout hotfix branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch_name }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Calculate hotfix version (auto-patch bump)
id: version
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "Current version: $CURRENT_VERSION"
# Remove any pre-release suffix
BASE_VERSION=$(echo $CURRENT_VERSION | sed 's/-.*$//')
IFS='.' read -r -a VERSION_PARTS <<< "$BASE_VERSION"
MAJOR="${VERSION_PARTS[0]}"
MINOR="${VERSION_PARTS[1]}"
PATCH="${VERSION_PARTS[2]}"
# Hotfixes always bump patch version
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "Hotfix version will be: $NEW_VERSION"
- name: Update version in all files
run: |
# Update package.json
bun x json -I -f package.json -e "this.version='${{ steps.version.outputs.new_version }}'"
# Update manifests
bun x json -I -f manifest/chrome.json -e "this.version='${{ steps.version.outputs.new_version }}'"
bun x json -I -f manifest/firefox.json -e "this.version='${{ steps.version.outputs.new_version }}'"
bun x json -I -f safari/Mue\ Extension/Resources/manifest.json -e "this.version='${{ steps.version.outputs.new_version }}'"
# Update Safari Xcode project
sed -i "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${{ steps.version.outputs.new_version }}/g" safari/Mue.xcodeproj/project.pbxproj
# Update constants.js
sed -i "s/export const VERSION = '[^']*'/export const VERSION = '${{ steps.version.outputs.new_version }}'/" src/config/constants.js
- name: Install dependencies
run: bun install
- name: Build extension
run: bun run build
env:
NODE_ENV: production
- name: Commit version bump
run: |
git add package.json manifest/chrome.json manifest/firefox.json safari/Mue\ Extension/Resources/manifest.json safari/Mue.xcodeproj/project.pbxproj src/config/constants.js
git commit -m "chore: hotfix version bump to ${{ steps.version.outputs.new_version }}"
- name: Merge hotfix to main
run: |
git fetch origin main
git checkout main
git merge --no-ff ${{ github.event.inputs.branch_name }} -m "fix: merge hotfix ${{ github.event.inputs.branch_name }} (#${{ steps.version.outputs.new_version }})"
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Hotfix v${{ steps.version.outputs.new_version }}: ${{ github.event.inputs.description }}"
git push origin main
git push origin "v${{ steps.version.outputs.new_version }}"
- name: Generate changelog
id: changelog
run: |
# Get commits from hotfix branch
git checkout ${{ github.event.inputs.branch_name }}
COMMITS=$(git log --pretty=format:"- %s (%h)" origin/main..${{ github.event.inputs.branch_name }})
{
echo "changelog<<EOF"
echo "### 🚨 Hotfix"
echo "${{ github.event.inputs.description }}"
echo ""
echo "### Changes"
echo "$COMMITS"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
run: |
git checkout main
RELEASE_NOTES=$(cat <<EOF
## 🚨 Mue Hotfix v${{ steps.version.outputs.new_version }}
**This is an emergency hotfix release.**
${{ steps.changelog.outputs.changelog }}
### 📦 Installation
**Browser Extensions (will be updated shortly):**
- **Chrome**: [Chrome Web Store](https://chromewebstore.google.com/detail/mue/bngmbednanpcfochchhgbkookpiaiaid)
- **Edge**: [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/mue/aepnglgjfokepefimhbnibfjekidhmja)
- **Firefox**: [Firefox Add-ons](https://addons.mozilla.org/en-GB/firefox/addon/mue/)
**Immediate Manual Installation:**
- Download the ZIP file below for immediate deployment
- Chrome/Edge: Load unpacked extension
- Firefox: Install from about:debugging
---
**⚠️ This hotfix should be submitted to stores immediately.**
EOF
)
gh release create "v${{ steps.version.outputs.new_version }}" \
"build/chrome-${{ steps.version.outputs.new_version }}.zip" \
"build/firefox-${{ steps.version.outputs.new_version }}.zip" \
--title "🚨 Hotfix v${{ steps.version.outputs.new_version }}" \
--notes "$RELEASE_NOTES" \
--latest
env:
GH_TOKEN: ${{ github.token }}
- name: Back-merge to beta
run: |
git fetch origin beta
git checkout beta
git merge --no-ff main -m "chore: back-merge hotfix v${{ steps.version.outputs.new_version }} from main"
git push origin beta
- name: Back-merge to dev
run: |
git fetch origin dev
git checkout dev
git merge --no-ff main -m "chore: back-merge hotfix v${{ steps.version.outputs.new_version }} from main"
git push origin dev
- name: Output success summary
run: |
echo "## 🚨 Hotfix Released!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: v${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "**Description**: ${{ github.event.inputs.description }}" >> $GITHUB_STEP_SUMMARY
echo "**Release URL**: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✅ Completed Actions" >> $GITHUB_STEP_SUMMARY
echo "- [x] Merged hotfix to \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- [x] Created tag v${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "- [x] Created GitHub Release" >> $GITHUB_STEP_SUMMARY
echo "- [x] Back-merged to \`beta\` branch" >> $GITHUB_STEP_SUMMARY
echo "- [x] Back-merged to \`dev\` branch" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🚨 URGENT: Manual Steps Required" >> $GITHUB_STEP_SUMMARY
echo "1. **Submit to stores IMMEDIATELY**:" >> $GITHUB_STEP_SUMMARY
echo " - Go to [Submit workflow](https://github.com/${{ github.repository }}/actions/workflows/submit.yml)" >> $GITHUB_STEP_SUMMARY
echo " - Run with tag: \`${{ steps.version.outputs.new_version }}\`" >> $GITHUB_STEP_SUMMARY
echo "2. Update [muetab.com/blog/changelog](https://muetab.com/blog/changelog)" >> $GITHUB_STEP_SUMMARY
echo "3. Notify users via Discord/social media" >> $GITHUB_STEP_SUMMARY
echo "4. Delete hotfix branch: \`${{ github.event.inputs.branch_name }}\`" >> $GITHUB_STEP_SUMMARY

187
.github/workflows/production-release.yml vendored Normal file
View File

@@ -0,0 +1,187 @@
name: Production Release
on:
pull_request:
branches:
- main
types:
- closed
permissions:
contents: write
jobs:
# Only run if PR was merged (not just closed)
check-merge:
if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'beta'
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check.outputs.should_release }}
steps:
- name: Check if this is a beta to main merge
id: check
run: |
echo "should_release=true" >> $GITHUB_OUTPUT
echo "✅ This is a beta → main merge, proceeding with production release" >> $GITHUB_STEP_SUMMARY
build-and-release:
needs: check-merge
if: needs.check-merge.outputs.should_release == 'true'
runs-on: ubuntu-latest
environment: production # Requires manual approval from maintainers
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
- name: Install dependencies
run: bun install
- name: Build extension
run: bun run build
env:
NODE_ENV: production
- name: Get version from package.json
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
# Remove any pre-release suffix for production
STABLE_VERSION=$(echo $VERSION | sed 's/-.*$//')
echo "version=$STABLE_VERSION" >> $GITHUB_OUTPUT
echo "full_version=$VERSION" >> $GITHUB_OUTPUT
echo "Building production version: $STABLE_VERSION"
- name: Generate production changelog
id: changelog
run: |
# Get the latest production (non-beta) tag
PREVIOUS_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v 'beta\|alpha\|rc' | head -n 1 || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous production tag found, using all commits"
COMMITS=$(git log --pretty=format:"- %s (%h)" main)
else
echo "Generating changelog from $PREVIOUS_TAG to main"
COMMITS=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..main)
fi
# Categorize commits
FEATURES=$(echo "$COMMITS" | grep -i "^- feat" || echo "")
FIXES=$(echo "$COMMITS" | grep -i "^- fix" || echo "")
PERFORMANCE=$(echo "$COMMITS" | grep -i "^- perf" || echo "")
BREAKING=$(echo "$COMMITS" | grep -i "BREAKING CHANGE" || echo "")
{
echo "changelog<<EOF"
if [ -n "$BREAKING" ]; then
echo "### ⚠️ Breaking Changes"
echo "$BREAKING"
echo ""
fi
if [ -n "$FEATURES" ]; then
echo "### ✨ New Features"
echo "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### 🐛 Bug Fixes"
echo "$FIXES"
echo ""
fi
if [ -n "$PERFORMANCE" ]; then
echo "### ⚡ Performance Improvements"
echo "$PERFORMANCE"
fi
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Check if tag exists
id: check_tag
run: |
if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create production tag
if: steps.check_tag.outputs.exists == 'false'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}"
git push origin "v${{ steps.version.outputs.version }}"
- name: Create GitHub Release
run: |
RELEASE_NOTES=$(cat <<EOF
## 🎉 Mue v${{ steps.version.outputs.version }}
${{ steps.changelog.outputs.changelog }}
### 📦 Installation
**Browser Extensions:**
- **Chrome**: [Chrome Web Store](https://chromewebstore.google.com/detail/mue/bngmbednanpcfochchhgbkookpiaiaid)
- **Edge**: [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/mue/aepnglgjfokepefimhbnibfjekidhmja)
- **Firefox**: [Firefox Add-ons](https://addons.mozilla.org/en-GB/firefox/addon/mue/)
**Manual Installation:**
- Download the appropriate ZIP file below
- Chrome/Edge: Load unpacked extension from extracted folder
- Firefox: Install from about:debugging → Load Temporary Add-on
### 🔗 Links
- **Demo**: [demo.muetab.com](https://demo.muetab.com)
- **Changelog**: [muetab.com/blog/changelog](https://muetab.com/blog/changelog)
- **Documentation**: [github.com/mue/mue](https://github.com/mue/mue)
---
### 📝 Build Information
- **Version**: ${{ steps.version.outputs.version }}
- **Build Date**: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
- **Commit**: ${{ github.sha }}
EOF
)
gh release create "v${{ steps.version.outputs.version }}" \
"build/chrome-${{ steps.version.outputs.version }}.zip" \
"build/firefox-${{ steps.version.outputs.version }}.zip" \
--title "Mue v${{ steps.version.outputs.version }}" \
--notes "$RELEASE_NOTES" \
--latest
env:
GH_TOKEN: ${{ github.token }}
- name: Output success summary
run: |
echo "## 🚀 Production Release Published!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Release URL**: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Build Artifacts" >> $GITHUB_STEP_SUMMARY
echo "- Chrome/Edge: \`chrome-${{ steps.version.outputs.version }}.zip\`" >> $GITHUB_STEP_SUMMARY
echo "- Firefox: \`firefox-${{ steps.version.outputs.version }}.zip\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
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: \`${{ 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
echo "- [ ] Submit to browser stores (see manual steps above)" >> $GITHUB_STEP_SUMMARY
echo "- [ ] Update [muetab.com/blog/changelog](https://muetab.com/blog/changelog)" >> $GITHUB_STEP_SUMMARY
echo "- [ ] Announce release on Discord/social media" >> $GITHUB_STEP_SUMMARY
echo "- [ ] Monitor issue tracker for bug reports" >> $GITHUB_STEP_SUMMARY
echo "- [ ] Merge \`main\` back to \`beta\` and \`dev\` to sync version" >> $GITHUB_STEP_SUMMARY

147
.github/workflows/version-bump.yml vendored Normal file
View File

@@ -0,0 +1,147 @@
name: Version Bump
on:
workflow_dispatch:
inputs:
bump_type:
description: 'Version bump type'
required: true
type: choice
options:
- patch # 7.5.0 -> 7.5.1 (bug fixes)
- minor # 7.5.0 -> 7.6.0 (new features)
- major # 7.5.0 -> 8.0.0 (breaking changes)
pre_release:
description: 'Pre-release label (leave empty for stable release)'
required: false
type: choice
options:
- ''
- beta
- rc
- alpha
permissions:
contents: write
jobs:
bump-version:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Calculate new version
id: version
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "Current version: $CURRENT_VERSION"
# Remove any pre-release suffix for base version
BASE_VERSION=$(echo $CURRENT_VERSION | sed 's/-.*$//')
IFS='.' read -r -a VERSION_PARTS <<< "$BASE_VERSION"
MAJOR="${VERSION_PARTS[0]}"
MINOR="${VERSION_PARTS[1]}"
PATCH="${VERSION_PARTS[2]}"
# If requesting a pre-release and current is already a pre-release, keep base version
# Otherwise, bump the version based on type
if [ -n "${{ github.event.inputs.pre_release }}" ] && [ "$IS_PRERELEASE" = "true" ]; then
# Keep existing base version for iterative betas (7.6.0-beta.1 -> 7.6.0-beta.2)
NEW_VERSION="$BASE_VERSION"
else
# Bump version based on type
case "${{ github.event.inputs.bump_type }}" in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
fi
# Add pre-release label if specified
if [ -n "${{ github.event.inputs.pre_release }}" ]; then
# Get beta number by counting existing beta tags for this version
BETA_COUNT=$(git tag -l "v${NEW_VERSION}-${{ github.event.inputs.pre_release }}.*" | wc -l)
BETA_NUM=$((BETA_COUNT + 1))
NEW_VERSION="${NEW_VERSION}-${{ github.event.inputs.pre_release }}.${BETA_NUM}"
fi
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version will be: $NEW_VERSION"
- name: Update package.json
run: |
bun x json -I -f package.json -e "this.version='${{ steps.version.outputs.new_version }}'"
- name: Update Chrome manifest
run: |
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
bun x json -I -f manifest/chrome.json -e "this.version='${VERSION_WITHOUT_PRERELEASE}'"
- name: Update Firefox manifest
run: |
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
bun x json -I -f manifest/firefox.json -e "this.version='${VERSION_WITHOUT_PRERELEASE}'"
- name: Update Safari manifest
run: |
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
bun x json -I -f safari/Mue\ Extension/Resources/manifest.json -e "this.version='${VERSION_WITHOUT_PRERELEASE}'"
- name: Update Safari Xcode project
run: |
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
sed -i "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${VERSION_WITHOUT_PRERELEASE}/g" safari/Mue.xcodeproj/project.pbxproj
- name: Update constants.js
run: |
sed -i "s/export const VERSION = '[^']*'/export const VERSION = '${{ steps.version.outputs.new_version }}'/" src/config/constants.js
- name: Commit version bump
run: |
git add package.json manifest/chrome.json manifest/firefox.json safari/Mue\ Extension/Resources/manifest.json safari/Mue.xcodeproj/project.pbxproj src/config/constants.js
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
- name: Push changes
run: |
git push origin ${{ github.ref_name }}
git push origin "v${{ steps.version.outputs.new_version }}"
- name: Summary
run: |
echo "✅ Version bumped to ${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "📦 Tag created: v${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Files updated:" >> $GITHUB_STEP_SUMMARY
echo "- package.json" >> $GITHUB_STEP_SUMMARY
echo "- manifest/chrome.json" >> $GITHUB_STEP_SUMMARY
echo "- manifest/firefox.json" >> $GITHUB_STEP_SUMMARY
echo "- safari/Mue Extension/Resources/manifest.json" >> $GITHUB_STEP_SUMMARY
echo "- safari/Mue.xcodeproj/project.pbxproj" >> $GITHUB_STEP_SUMMARY
echo "- src/config/constants.js" >> $GITHUB_STEP_SUMMARY

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@ node_modules/
build/
.idea/
dist/
/docs
# Safari Extension Build Files
safari/Mue Extension/Resources/*.html

310
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,310 @@
# Contributing to Mue
Thanks for your interest in contributing to Mue! We welcome contributions from the community.
## 📋 Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Workflow](#development-workflow)
- [Branch Strategy](#branch-strategy)
- [Making Changes](#making-changes)
- [Commit Messages](#commit-messages)
- [Pull Request Process](#pull-request-process)
- [Release Process](#release-process)
## Code of Conduct
Please be respectful and constructive in your interactions with the community.
## Getting Started
### Prerequisites
- [Bun](https://bun.sh/) >= 1.3.0
- Node.js >= 20.0.0 (for some tooling)
- Git
### Setup
1. Fork the repository
2. Clone your fork:
```bash
git clone https://github.com/YOUR_USERNAME/mue.git
cd mue
```
3. Install dependencies:
```bash
bun install
```
4. Start development server:
```bash
bun run dev
```
## Development Workflow
### Scripts
- `bun run dev` - Start development server with hot reload
- `bun run dev:host` - Start development server accessible on network
- `bun run build` - Build production extension for all browsers
- `bun run lint` - Run ESLint and Stylelint
- `bun run lint:fix` - Auto-fix linting issues
- `bun run pretty` - Format code with Prettier
### Testing Your Changes
1. Load the extension in your browser:
- **Chrome/Edge**: Go to `chrome://extensions`, enable Developer mode, click "Load unpacked", select `dist` folder
- **Firefox**: Go to `about:debugging#/runtime/this-firefox`, click "Load Temporary Add-on", select any file in `dist` folder
2. Test your changes thoroughly across different browsers
## Branch Strategy
Mue uses a three-branch workflow:
```
dev (active development)
beta (release candidates)
main (production/stable)
```
### Branches
- **`dev`** - Active development branch
- All feature and bug fix PRs merge here first
- Maintainers can push directly for small fixes
- Contributors must create PRs
- CI must pass before merge
- **`beta`** - Release candidate testing
- PRs from `dev` → `beta` for release candidates
- Triggers beta release workflow
- Requires 2 maintainer approvals
- Used for testing with beta testers before production
- **`main`** - Production/stable releases
- PRs from `beta` → `main` only
- Triggers production release workflow
- Requires 2 maintainer approvals + manual environment approval
- Represents current live extension version
### Special Branches
- **`hotfix/*`** - Emergency production fixes
- Branch from `main` for critical bugs
- Triggers hotfix workflow (auto-merges to all branches)
- Maintainers only
## Making Changes
### For Contributors
1. Create a feature branch from `dev`:
```bash
git checkout dev
git pull origin dev
git checkout -b feature/your-feature-name
```
2. Make your changes following our code style
3. Test your changes locally
4. Commit your changes (see [Commit Messages](#commit-messages))
5. Push to your fork:
```bash
git push origin feature/your-feature-name
```
6. Create a Pull Request targeting the `dev` branch
### For Maintainers
Maintainers can push directly to `dev` for small fixes, or follow the contributor process for larger changes.
## Commit Messages
We use [Conventional Commits](https://www.conventionalcommits.org/) for automated changelog generation.
### Format
```
<type>(<scope>): <description>
[optional body]
[optional footer]
```
### Types
- `feat:` - New feature
- `fix:` - Bug fix
- `perf:` - Performance improvement
- `docs:` - Documentation changes
- `style:` - Code style changes (formatting, etc.)
- `refactor:` - Code refactoring
- `test:` - Adding or updating tests
- `chore:` - Maintenance tasks, dependency updates
### Examples
```bash
feat(weather): add hourly forecast widget
fix(greeting): resolve time zone display issue
perf(background): optimize image loading
docs(readme): update installation instructions
chore: bump version to 7.6.0
```
### Breaking Changes
For breaking changes, add `BREAKING CHANGE:` in the commit body:
```bash
feat(api): change settings storage format
BREAKING CHANGE: Settings format has changed. Users will need to reconfigure their settings.
```
## Pull Request Process
1. **Fill out the PR template** completely
2. **Ensure all checks pass**:
- ✅ Build succeeds
- ✅ Linting passes
- ✅ No merge conflicts
3. **Get reviews**:
- Contributors: 1 maintainer approval required
- Beta → Main: 2 maintainer approvals required
4. **Address review feedback**
5. **Squash commits** if requested (maintainers can squash on merge)
6. **Wait for merge** - maintainers will merge when ready
### PR Guidelines
- Keep PRs focused on a single feature/fix
- Include screenshots/videos for UI changes
- Update documentation if needed
- Link related issues
- Test on multiple browsers
## Release Process
### Version Numbering
We follow [Semantic Versioning](https://semver.org/):
- **Major** (8.0.0): Breaking changes, major feature overhauls
- **Minor** (7.6.0): New features, backward-compatible
- **Patch** (7.5.1): Bug fixes, small improvements
### Release Workflow (Maintainers Only)
#### 1. Development Phase
Contributors and maintainers work on `dev` branch.
#### 2. Create Beta Release
When ready for testing:
1. Run version bump workflow:
```
Actions → Version Bump → Run workflow
- Branch: dev
- Bump type: minor/major/patch
- Pre-release: beta
```
2. Create PR from `dev` → `beta`
3. Merge PR (triggers beta release workflow)
4. Share beta release with testers
5. Gather feedback and fix issues on `dev`
6. Repeat until stable
#### 3. Promote to Production
When beta is stable:
1. Create PR from `beta` → `main` with checklist:
- [ ] Beta tested for X days
- [ ] All critical bugs resolved
- [ ] Y+ testers approved
- [ ] Release notes prepared
2. Get 2 maintainer approvals
3. Merge PR (triggers production release workflow)
4. Workflow pauses for manual approval (10 min wait)
5. Approve in GitHub Actions → Environments → production
6. Release is created on GitHub
7. Manually trigger store submission:
```
Actions → Submit → Run workflow
- Enter version tag (e.g., 7.6.0)
```
#### 4. Emergency Hotfix
For critical production bugs:
1. Create hotfix branch from `main`:
```bash
git checkout main
git pull origin main
git checkout -b hotfix/critical-bug-fix
```
2. Fix the issue and commit
3. Push branch:
```bash
git push origin hotfix/critical-bug-fix
```
4. Run hotfix workflow:
```
Actions → Hotfix Release → Run workflow
- Description: Brief description
- Branch name: hotfix/critical-bug-fix
```
5. Workflow will:
- Auto-bump patch version
- Merge to `main`
- Create release
- Back-merge to `beta` and `dev`
6. Immediately submit to stores
## Questions?
- 💬 Join our [Discord](https://discord.gg/zv8C9F8) (if available)
- 📧 Email: [contact info]
- 🐛 Report bugs: [GitHub Issues](https://github.com/mue/mue/issues)
## License
By contributing, you agree that your contributions will be licensed under the BSD-3-Clause License.

View File

@@ -49,6 +49,16 @@ Install dependencies with `bun install`, and then you can run any of the followi
- `bun run translations` - migrate old translation format to new
- `bun run translations:percentages` - update translation completion percentages from Weblate
### Contributing
We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on:
- Development workflow
- Branch strategy (dev → beta → main)
- Commit message format
- Pull request process
For maintainers, see [docs/RELEASE_PROCESS.md](docs/RELEASE_PROCESS.md) for release procedures.
## 🐳 Docker development
Hot reload is available while coding.

1329
bun.lock

File diff suppressed because it is too large Load Diff

469
docs/RELEASE_PROCESS.md Normal file
View File

@@ -0,0 +1,469 @@
# Mue Release Process
This document outlines the complete release process for Mue maintainers.
## 📋 Table of Contents
- [Overview](#overview)
- [Version Numbering](#version-numbering)
- [Pre-Release Checklist](#pre-release-checklist)
- [Beta Release Process](#beta-release-process)
- [Production Release Process](#production-release-process)
- [Hotfix Release Process](#hotfix-release-process)
- [Post-Release Tasks](#post-release-tasks)
- [Store Submission](#store-submission)
- [Troubleshooting](#troubleshooting)
## Overview
Mue uses a three-branch release workflow:
```
dev → beta → main
```
- **`dev`**: Active development and feature integration
- **`beta`**: Release candidates for community testing
- **`main`**: Production-ready stable releases
## Version Numbering
We follow [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PATCH`
### When to Bump
| Type | When | Example |
|------|------|---------|
| **Major** (x.0.0) | Breaking changes, API changes, major UI overhaul | 7.5.0 → 8.0.0 |
| **Minor** (0.x.0) | New features, backward-compatible changes | 7.5.0 → 7.6.0 |
| **Patch** (0.0.x) | Bug fixes, small improvements | 7.5.0 → 7.5.1 |
### Beta Versions
Beta versions follow the format: `MAJOR.MINOR.PATCH-beta.X`
Example: `7.6.0-beta.1`, `7.6.0-beta.2`
## Pre-Release Checklist
Before starting any release:
- [ ] All intended features/fixes are merged to `dev`
- [ ] No critical bugs in issue tracker
- [ ] `dev` branch builds successfully
- [ ] All CI checks passing
- [ ] Translation updates synced from Weblate (if applicable)
- [ ] Breaking changes documented
## Beta Release Process
### Step 1: Version Bump
1. Go to **Actions****Version Bump****Run workflow**
2. Configure:
- **Branch**: `dev`
- **Bump type**: Choose `patch`, `minor`, or `major`
- **Pre-release**: Select `beta`
3. Click **Run workflow**
4. Workflow will:
- Calculate new version (e.g., `7.6.0-beta.1`)
- Update all 6 version files
- Create git tag
- Push to `dev`
### Step 2: Create Beta PR
1. Go to **Pull Requests****New pull request**
2. Configure:
- Base: `beta`
- Compare: `dev`
3. Fill in PR template:
- Add changelog preview
- List major changes
- Add testing notes
4. Get 2 maintainer approvals
### Step 3: Merge and Release
1. **Merge PR** to `beta` branch
2. **Beta Release Workflow** auto-triggers:
- Builds extension for all browsers
- Creates GitHub pre-release
- Uploads Chrome/Firefox ZIPs
- Generates changelog
3. **Verify release**:
- Check [Releases page](https://github.com/mue/mue/releases)
- Download and test ZIPs
- Verify version numbers
### Step 4: Beta Testing
1. **Share with testers**:
- Post release link in Discord/testing channel
- Include installation instructions
- Provide feedback form/issue template
2. **Monitor feedback**:
- Track issues tagged with beta version
- Prioritize critical bugs
- Document all feedback
3. **Fix issues**:
- Fix bugs on `dev` branch
- Create new beta (repeat from Step 1)
- Increment beta number (7.6.0-beta.2, etc.)
4. **Minimum beta period**: 3-7 days (depending on changes)
5. **Stability criteria**:
- No P0/P1 bugs reported
- Positive feedback from 5+ testers
- All critical features tested
## Production Release Process
### Step 1: Pre-Production Checks
- [ ] Beta has been stable for minimum period
- [ ] All critical beta bugs resolved
- [ ] Release notes prepared
- [ ] Store credentials verified
- [ ] Team notified of pending release
### Step 2: Version Bump to Stable
1. Go to **Actions****Version Bump****Run workflow**
2. Configure:
- **Branch**: `beta`
- **Bump type**: Usually same as beta (minor/major/patch)
- **Pre-release**: Leave empty (stable release)
3. This updates `7.6.0-beta.X``7.6.0`
### Step 3: Create Production PR
1. Go to **Pull Requests****New pull request**
2. Configure:
- Base: `main`
- Compare: `beta`
3. Fill in **release PR checklist**:
- [ ] Beta tested for X days
- [ ] All critical bugs resolved
- [ ] 5+ beta testers approved
- [ ] Release notes prepared
- [ ] Store submission ready
- [ ] Changelog updated on website
4. Get 2 maintainer approvals
### Step 4: Merge and Release
1. **Merge PR** to `main`
2. **Production Release Workflow** starts:
- Builds extension
- Creates production tag
- Generates full changelog
- Creates GitHub release
- **Pauses for manual approval**
3. **Review in GitHub**:
- Go to **Actions****Production Release** → running workflow
- Review release notes
- Check build artifacts
- **Approve deployment** in Environments → production
4. **Wait 10 minutes** (cooldown period)
5. **Release completes**:
- GitHub release published
- ZIPs uploaded
- Tag created
### Step 5: Store Submission
**Manual submission required** (for now):
1. Go to **Actions****Submit****Run workflow**
2. Enter version tag: `7.6.0` (no 'v' prefix)
3. Click **Run workflow**
4. Monitor submission workflow:
- Chrome Web Store submission
- Firefox Add-ons submission
- Edge Add-ons submission
5. **Verify store listings**:
- Chrome: https://chromewebstore.google.com/detail/mue/bngmbednanpcfochchhgbkookpiaiaid
- Edge: https://microsoftedge.microsoft.com/addons/detail/mue/aepnglgjfokepefimhbnibfjekidhmja
- Firefox: https://addons.mozilla.org/en-GB/firefox/addon/mue/
6. **Store review times**:
- Chrome: 1-3 days
- Edge: 1-2 days
- Firefox: hours to days
### Step 6: Sync Branches
After production release, sync version to other branches:
```bash
# Update dev and beta with main
git checkout dev
git pull origin dev
git merge main
git push origin dev
git checkout beta
git pull origin beta
git merge main
git push origin beta
```
## Hotfix Release Process
### When to Use Hotfix
**Only for critical production bugs:**
- Security vulnerabilities
- Data loss bugs
- Extension completely broken
- Critical functionality broken for all users
### Process
1. **Create hotfix branch** from `main`:
```bash
git checkout main
git pull origin main
git checkout -b hotfix/brief-description
```
2. **Fix the bug**:
- Make minimal changes (hotfix only)
- Test thoroughly
- Commit with conventional format
3. **Push branch**:
```bash
git push origin hotfix/brief-description
```
4. **Run hotfix workflow**:
- Go to **Actions** → **Hotfix Release** → **Run workflow**
- **Description**: Brief bug description
- **Branch name**: `hotfix/brief-description`
- Click **Run workflow**
5. **Approve deployment**:
- Workflow pauses for approval
- Review changes carefully
- Approve in **Environments** → **production**
6. **Workflow automatically**:
- Bumps patch version (7.6.0 → 7.6.1)
- Merges to `main`
- Creates release tag
- Builds and releases
- Back-merges to `beta` and `dev`
7. **Submit to stores immediately**:
- Go to **Actions** → **Submit** → **Run workflow**
- Enter new version (e.g., `7.6.1`)
8. **Notify users**:
- Post urgent update notice
- Update website changelog
- Notify via social media if critical
9. **Clean up**:
```bash
git push origin --delete hotfix/brief-description
```
## Post-Release Tasks
After any production release:
### Immediate (within 24 hours)
- [ ] Verify store submissions completed
- [ ] Update https://muetab.com/blog/changelog
- [ ] Announce on Discord/social media
- [ ] Monitor issue tracker for new reports
- [ ] Verify demo site (demo.muetab.com) is updated
### Within 1 Week
- [ ] Review analytics for adoption rate
- [ ] Address any quick-fix bugs as patch release
- [ ] Update roadmap/milestones
- [ ] Thank beta testers and contributors
### Ongoing
- [ ] Monitor store reviews/ratings
- [ ] Respond to user feedback
- [ ] Plan next release cycle
## Store Submission
### Required Credentials
Stored in GitHub Secrets as `SUBMIT_KEYS`:
```json
{
"chrome": {
"extId": "bngmbednanpcfochchhgbkookpiaiaid",
"clientId": "...",
"clientSecret": "...",
"refreshToken": "..."
},
"firefox": {
"extId": "{ac143a20-4b61-4c81-abdd-4bff77032972}",
"jwtIssuer": "...",
"jwtSecret": "..."
},
"edge": {
"productId": "...",
"clientId": "...",
"clientSecret": "...",
"accessTokenUrl": "..."
}
}
```
### Beta Distribution
**Chrome/Edge Beta**:
- Use unlisted listing (share link with testers)
- Or use trusted testers group (max 1000)
**Firefox Beta**:
- Upload as unlisted to AMO
- Share download link from GitHub Releases
**Safari Beta**:
- Currently manual sideload from GitHub Releases
## Troubleshooting
### Build Fails
**Issue**: Build fails in workflow
**Solutions**:
1. Check CI logs for specific error
2. Run `bun run build` locally to reproduce
3. Ensure all dependencies installed
4. Check for linting errors: `bun run lint`
### Version Mismatch
**Issue**: Version numbers don't match across files
**Solutions**:
1. Re-run Version Bump workflow
2. Manually verify all 6 files:
- package.json
- manifest/chrome.json
- manifest/firefox.json
- safari/Mue Extension/Resources/manifest.json
- safari/Mue.xcodeproj/project.pbxproj
- src/config/constants.js
### Tag Already Exists
**Issue**: Git tag already exists for version
**Solutions**:
1. Delete existing tag:
```bash
git tag -d v7.6.0
git push origin :refs/tags/v7.6.0
```
2. Re-run workflow
### Store Submission Fails
**Issue**: PlasmoHQ BPP submission fails
**Solutions**:
1. Check workflow logs for specific error
2. Verify credentials in `SUBMIT_KEYS` secret
3. Check store developer console for issues
4. Try manual submission as fallback
### Merge Conflicts
**Issue**: Conflicts when merging beta → main
**Solutions**:
1. Update beta with main first:
```bash
git checkout beta
git merge main
git push origin beta
```
2. Create new PR from beta → main
## Emergency Rollback
If a production release has critical bugs:
### Option 1: Hotfix (Preferred)
Follow [Hotfix Process](#hotfix-release-process) to quickly patch and release.
### Option 2: Store Rollback
Each store allows rolling back to previous version:
**Chrome Web Store**:
1. Go to Developer Dashboard
2. Select Mue extension
3. Package → Select previous version
4. Publish
**Firefox Add-ons**:
1. Go to Developer Hub
2. Select Mue add-on
3. Manage Status & Versions
4. Enable previous version
**Edge Add-ons**:
1. Go to Partner Center
2. Select Mue extension
3. Packages → Restore previous
### Option 3: Revert and Re-release
```bash
git checkout main
git revert <commit-hash>
git push origin main
```
Then follow production release process.
## Questions?
Contact maintainers:
- @davidcralph
- @alexsparkes
Or open a discussion: https://github.com/mue/mue/discussions

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.0",
"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.0",
"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.0;
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.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -445,7 +445,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 7.5.0;
MARKETING_VERSION = 7.6.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -486,7 +486,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 7.5.0;
MARKETING_VERSION = 7.6.0;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

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,22 +25,24 @@ function AddModal({ urlError, iconError, addLink, closeModal, edit, editData, ed
</Tooltip>
</div>
<div className="quicklinkModalTextbox">
<TextareaAutosize
maxRows={1}
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('widgets.quicklinks.name')}
value={name}
onChange={(e) => setName(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
style={{ gridColumn: 'span 2' }}
/>
<TextareaAutosize
maxRows={10}
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('widgets.quicklinks.url')}
value={url}
onChange={(e) => setUrl(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
/>
<TextareaAutosize
maxRows={10}
maxLines={1}
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('widgets.quicklinks.icon')}
value={icon}
onChange={(e) => setIcon(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}

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;

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,20 @@
width: 60px !important;
border-radius: 12px;
transition: 0.5s;
&.item-icon-text {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
@include themed {
background-color: t($modal-sidebarActive);
color: t($color);
}
}
}
.card-details {
@@ -113,6 +110,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 +154,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 +214,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 +326,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 +349,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;

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

@@ -213,6 +213,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 +288,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

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

@@ -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,7 @@
import { useState, memo } from 'react';
import { useState, memo, useRef, useEffect } from 'react';
import { MdExpandMore, MdClose } from 'react-icons/md';
import Box from '@mui/material/Box';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import './ChipSelect.scss';
function ChipSelect({ label, options, onChange }) {
let start = (localStorage.getItem('apiCategories') || '').split(',');
@@ -14,47 +9,88 @@ function ChipSelect({ label, options, onChange }) {
start = [];
}
const [optionsSelected, setoptionsSelected] = useState(start);
const [optionsSelected, setOptionsSelected] = useState(start);
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
const handleChange = (event) => {
const {
target: { value },
} = event;
setoptionsSelected(typeof value === 'string' ? value.split(',') : value);
localStorage.setItem('apiCategories', value);
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleToggle = (optionName) => {
let newSelected;
if (optionsSelected.includes(optionName)) {
newSelected = optionsSelected.filter((item) => item !== optionName);
} else {
newSelected = [...optionsSelected, optionName];
}
setOptionsSelected(newSelected);
localStorage.setItem('apiCategories', newSelected.join(','));
// Call parent onChange if provided
if (onChange) {
onChange(value);
onChange(newSelected);
}
};
const handleRemoveChip = (e, optionName) => {
e.stopPropagation();
handleToggle(optionName);
};
return (
<FormControl>
<InputLabel id="chipSelect-label">{label}</InputLabel>
<Select
labelId="chipSelect-label"
id="chipSelect"
multiple
value={optionsSelected}
onChange={handleChange}
input={<OutlinedInput id="select-multiple-chip" label={label} />}
renderValue={(optionsSelected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{optionsSelected.map((value) => (
<Chip key={value} label={value} />
))}
</Box>
)}
>
{options.map((option) => (
<MenuItem key={option.name} value={option.name}>
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}{' '}
{option.count && `(${option.count})`}
</MenuItem>
))}
</Select>
</FormControl>
<div className="chipSelect" ref={containerRef}>
{label && <label className="chipSelect-label">{label}</label>}
<div className="chipSelect-control" onClick={() => setIsOpen(!isOpen)}>
<div className="chipSelect-value">
{optionsSelected.length === 0 ? (
<span className="chipSelect-placeholder">Select options...</span>
) : (
<div className="chipSelect-chips">
{optionsSelected.map((value) => (
<span key={value} className="chipSelect-chip">
{value.charAt(0).toUpperCase() + value.slice(1)}
<button
type="button"
className="chipSelect-chip-remove"
onClick={(e) => handleRemoveChip(e, value)}
>
<MdClose />
</button>
</span>
))}
</div>
)}
</div>
<MdExpandMore className={`chipSelect-arrow ${isOpen ? 'open' : ''}`} />
</div>
{isOpen && (
<div className="chipSelect-dropdown">
{options.map((option) => (
<div
key={option.name}
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
onClick={() => handleToggle(option.name)}
>
<span className="chipSelect-option-checkbox">
{optionsSelected.includes(option.name) && '✓'}
</span>
<span className="chipSelect-option-label">
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
{option.count && ` (${option.count})`}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,178 @@
@use 'scss/variables' as *;
.chipSelect {
position: relative;
width: 300px;
margin-top: 10px;
.chipSelect-label {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
}
}
.chipSelect-control {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 56px;
padding: 8px 12px;
cursor: pointer;
transition: 0.2s ease;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover {
border-color: t($color);
}
}
}
.chipSelect-value {
flex: 1;
min-width: 0;
}
.chipSelect-placeholder {
@include themed {
color: t($subColor);
}
}
.chipSelect-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chipSelect-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 13px;
text-transform: capitalize;
@include themed {
background: t($modal-sidebarActive);
border-radius: calc(t($borderRadius) / 2);
color: t($color);
}
.chipSelect-chip-remove {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
margin-left: 2px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 50%;
transition: 0.2s ease;
@include themed {
color: t($subColor);
&:hover {
background: rgba(255, 255, 255, 0.1);
color: t($color);
}
}
svg {
font-size: 14px;
}
}
}
.chipSelect-arrow {
flex-shrink: 0;
font-size: 24px;
transition: transform 0.2s ease;
@include themed {
color: t($subColor);
}
&.open {
transform: rotate(180deg);
}
}
.chipSelect-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
z-index: 100;
@include themed {
background: t($modal-background);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.chipSelect-option {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
cursor: pointer;
transition: 0.2s ease;
@include themed {
color: t($color);
&:hover {
background: t($modal-sidebarActive);
}
&.selected {
background: t($modal-sidebar);
}
}
.chipSelect-option-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 12px;
font-weight: bold;
@include themed {
border: 2px solid t($modal-sidebarActive);
border-radius: 4px;
color: t($color);
}
}
&.selected .chipSelect-option-checkbox {
@include themed {
background: t($modal-sidebarActive);
border-color: t($color);
}
}
.chipSelect-option-label {
flex: 1;
}
}
}

View File

@@ -1,69 +1,165 @@
import variables from 'config/variables';
import { memo, useState, useCallback, useRef } from 'react';
import { InputLabel, MenuItem, FormControl, Select } from '@mui/material';
import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md';
import { toast } from 'react-toastify';
import EventBus from 'utils/eventbus';
import './Dropdown.scss';
const Dropdown = memo((props) => {
const [value, setValue] = useState(
localStorage.getItem(props.name) || props.items[0].value,
localStorage.getItem(props.name) || props.items[0]?.value,
);
const dropdown = useRef();
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef(null);
const optionsRef = useRef([]);
const onChange = useCallback((e) => {
const newValue = e.target.value;
if (newValue === variables.getMessage('modals.main.loading')) {
return;
}
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
setValue(newValue);
if (!props.noSetting) {
localStorage.setItem(props.name, newValue);
localStorage.setItem(props.name2, props.value2);
}
if (props.onChange) {
props.onChange(newValue);
}
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false);
setFocusedIndex(-1);
}
}
};
EventBus.emit('refresh', props.category);
}, [value, props]);
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const onChange = useCallback(
(newValue) => {
if (newValue === variables.getMessage('modals.main.loading')) {
return;
}
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
setValue(newValue);
setIsOpen(false);
setFocusedIndex(-1);
if (!props.noSetting) {
localStorage.setItem(props.name, newValue);
localStorage.setItem(props.name2, props.value2);
}
if (props.onChange) {
props.onChange(newValue);
}
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
}
}
EventBus.emit('refresh', props.category);
},
[value, props],
);
const handleKeyDown = useCallback(
(e) => {
if (props.disabled) return;
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
setIsOpen(!isOpen);
break;
case 'Escape':
setIsOpen(false);
setFocusedIndex(-1);
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev));
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
}
break;
}
},
[isOpen, props.items, props.disabled],
);
const handleOptionKeyDown = useCallback(
(e, item) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(item.value);
}
},
[onChange],
);
const resetItem = useCallback(() => {
const defaultValue = props.default || props.items[0]?.value;
onChange(defaultValue);
toast(variables.getMessage('toasts.reset'));
}, [onChange, props.default, props.items]);
const id = 'dropdown' + props.name;
const label = props.label || '';
const selectedItem = props.items.find((item) => item?.value === value);
return (
<FormControl fullWidth className={id}>
<InputLabel id={id}>{label}</InputLabel>
<Select
labelId={id}
id={props.name}
value={value}
label={label}
onChange={onChange}
ref={dropdown}
key={id}
<div className={`dropdown ${id} ${props.disabled ? 'disabled' : ''}`} ref={containerRef}>
{label && (
<div className="dropdown-header">
<label className="dropdown-label">{label}</label>
<span className="dropdown-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
)}
<div
className="dropdown-control"
onClick={() => !props.disabled && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
role="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label || props.name}
tabIndex={props.disabled ? -1 : 0}
>
{props.items.map((item) =>
item !== null ? (
<MenuItem key={id + item.value} value={item.value}>
{item.text}
</MenuItem>
) : null,
)}
</Select>
</FormControl>
<span className="dropdown-value">{selectedItem?.text || value}</span>
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
</div>
{isOpen && (
<div className="dropdown-menu" role="listbox">
{props.items.map((item, index) =>
item !== null ? (
<div
key={id + item.value}
ref={(el) => (optionsRef.current[index] = el)}
className={`dropdown-option ${value === item.value ? 'selected' : ''} ${index === focusedIndex ? 'focused' : ''}`}
onClick={() => onChange(item.value)}
onKeyDown={(e) => handleOptionKeyDown(e, item)}
role="option"
aria-selected={value === item.value}
tabIndex={0}
>
<span className="dropdown-option-text">{item.text}</span>
{value === item.value && <MdCheck className="dropdown-option-check" />}
</div>
) : null,
)}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,218 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(dropdownSlideIn) {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.dropdown {
position: relative;
width: 300px;
margin-top: 10px;
gap: 8px;
display: flex;
flex-flow: column;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.dropdown-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
}
}
.dropdown-reset {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($link);
}
&:hover {
opacity: 0.8;
}
svg {
font-size: 12px;
}
}
.dropdown-control {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 16px;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover {
border-color: t($color);
}
}
&:focus-visible {
outline: none;
@include themed {
border-color: t($link);
box-shadow: 0 0 0 3px t($link);
}
}
}
.dropdown-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s ease;
}
.dropdown-arrow {
flex-shrink: 0;
font-size: 24px;
transition: transform 0.2s ease;
@include themed {
color: t($subColor);
}
&.open {
transform: rotate(180deg);
}
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
z-index: 9999;
@include animation(dropdownSlideIn 0.2s ease-out);
@include themed {
background: t($modal-background);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
@include themed {
background: t($modal-sidebar);
}
}
&::-webkit-scrollbar-thumb {
@include themed {
background: t($modal-sidebarActive);
border-radius: 3px;
}
&:hover {
@include themed {
background: t($color);
}
}
}
}
.dropdown-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
@include themed {
color: t($color);
&:hover {
background: t($modal-sidebarActive);
padding-left: 20px;
}
&.selected {
background: t($modal-sidebar);
font-weight: 500;
}
&.focused {
background: t($modal-sidebarActive);
border-left: 2px solid t($link);
}
}
.dropdown-option-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-option-check {
flex-shrink: 0;
font-size: 14px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
@include themed {
background: t($link);
color: white;
}
}
}
}

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

@@ -1,85 +1,88 @@
import variables from 'config/variables';
import { memo, useState, useCallback } from 'react';
import { useTranslation } from 'contexts/TranslationContext';
import {
Radio as RadioUI,
RadioGroup,
FormControlLabel,
FormControl,
FormLabel,
} from '@mui/material';
import EventBus from 'utils/eventbus';
import './Radio.scss';
const Radio = memo((props) => {
const { changeLanguage } = useTranslation();
const [value, setValue] = useState(localStorage.getItem(props.name));
const handleChange = useCallback(async (e) => {
const newValue = e.target.value;
const handleChange = useCallback(
async (newValue) => {
if (newValue === 'loading') {
return;
}
if (newValue === 'loading') {
return;
}
if (props.name === 'language') {
changeLanguage(newValue);
setValue(newValue);
if (props.name === 'language') {
// Use context to change language directly - no EventBus needed
changeLanguage(newValue);
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
if (props.onChange) {
props.onChange(newValue);
}
EventBus.emit('refresh', props.category);
return;
}
localStorage.setItem(props.name, newValue);
setValue(newValue);
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
if (props.onChange) {
props.onChange(newValue);
}
EventBus.emit('refresh', props.category);
return;
}
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
localStorage.setItem(props.name, newValue);
setValue(newValue);
if (props.onChange) {
props.onChange(newValue);
}
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
}
}
}
EventBus.emit('refresh', props.category);
}, [value, props, changeLanguage]);
EventBus.emit('refresh', props.category);
},
[value, props, changeLanguage],
);
return (
<FormControl component="fieldset">
<FormLabel
className={props.smallTitle ? 'radio-title-small' : 'radio-title'}
component="legend"
>
{props.title}
</FormLabel>
<RadioGroup
aria-label={props.name}
name={props.name}
onChange={handleChange}
value={value}
>
<div className="radio-group">
{props.title && (
<legend className={props.smallTitle ? 'radio-title-small' : 'radio-title'}>
{props.title}
</legend>
)}
<div className="radio-options" role="radiogroup" aria-label={props.name}>
{props.options.map((option) => (
<FormControlLabel
value={option.value}
control={<RadioUI />}
label={option.name}
<label
key={option.value}
/>
className={`radio-option ${value === option.value ? 'selected' : ''} ${option.disabled || props.disabled ? 'disabled' : ''}`}
>
<span className="radio-label">{option.name}</span>
<input
type="radio"
name={props.name}
value={option.value}
checked={value === option.value}
onChange={() => handleChange(option.value)}
disabled={option.disabled || props.disabled || false}
className="radio-input"
aria-label={option.name}
tabIndex={0}
/>
<div className="radio-circle">
{value === option.value && <div className="radio-dot" />}
</div>
</label>
))}
</RadioGroup>
</FormControl>
</div>
</div>
);
});

View File

@@ -0,0 +1,159 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(radioDotScale) {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.radio-group {
width: 100%;
margin-top: 10px;
.radio-title {
font-weight: bold;
font-size: 1.17rem;
margin-bottom: 12px;
display: block;
@include themed {
color: t($color);
}
}
.radio-title-small {
font-weight: bold;
font-size: 1rem;
margin-bottom: 10px;
display: block;
@include themed {
color: t($color);
}
}
.radio-options {
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-option {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 16px 20px;
transition: all 0.2s ease;
@include themed {
background: t($modal-sidebar);
border-radius: t($borderRadius);
box-shadow: 0 0 0 1px t($modal-sidebarActive);
&:hover:not(.disabled) {
background: t($modal-secondaryColour);
transform: translateY(-1px);
}
}
&:active:not(.disabled) {
transform: translateY(0);
}
&.selected .radio-circle {
@include themed {
border-color: t($link);
}
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
}
.radio-label {
flex: 1;
font-size: 15px;
pointer-events: none;
@include themed {
color: t($color);
}
}
.radio-circle {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
transition: all 0.2s ease;
cursor: pointer;
flex-shrink: 0;
margin-left: 20px;
pointer-events: none;
@include themed {
border: 2px solid t($modal-sidebarActive);
background: t($modal-secondaryColour);
}
&:hover:not(.disabled) {
transform: scale(1.1);
}
&:active:not(.disabled) {
@include themed {
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
}
}
}
.radio-dot {
width: 12px;
height: 12px;
border-radius: 50%;
@include animation(radioDotScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
@include themed {
background: t($link);
}
}
.radio-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
&:focus-visible + .radio-circle {
@include themed {
box-shadow: 0 0 0 3px t($link);
}
}
&:disabled {
cursor: not-allowed;
}
}
}

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,20 @@
import variables from 'config/variables';
import { memo, useState, useCallback } from 'react';
import { memo, useState, useCallback, useRef } from 'react';
import { toast } from 'react-toastify';
import { Slider } from '@mui/material';
import { MdRefresh } from 'react-icons/md';
import EventBus from 'utils/eventbus';
import './Slider.scss';
const SliderComponent = memo((props) => {
const [value, setValue] = useState(localStorage.getItem(props.name) || props.default);
const animationRef = useRef(null);
const handleChange = useCallback((e, text) => {
let newValue = e.target.value;
newValue = Number(newValue);
if (text) {
if (newValue === '') {
setValue(0);
return;
}
const handleChange = useCallback(
(e) => {
let newValue = e.target.value;
newValue = Number(newValue);
if (newValue > props.max) {
newValue = props.max;
@@ -26,52 +23,104 @@ const SliderComponent = memo((props) => {
if (newValue < props.min) {
newValue = props.min;
}
}
localStorage.setItem(props.name, newValue);
setValue(newValue);
localStorage.setItem(props.name, newValue);
setValue(newValue);
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
}
}
}
EventBus.emit('refresh', props.category);
}, [props]);
EventBus.emit('refresh', props.category);
},
[props],
);
const resetItem = useCallback(() => {
handleChange({
target: {
value: props.default || '',
},
});
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
const startValue = Number(value);
const endValue = Number(props.default || 0);
const duration = 300; // milliseconds
const startTime = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function for smooth animation
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
const currentValue = startValue + (endValue - startValue) * easeOutCubic;
const roundedValue = Math.round(currentValue / (Number(props.step) || 1)) * (Number(props.step) || 1);
localStorage.setItem(props.name, roundedValue);
setValue(roundedValue);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
// Ensure we end exactly at the target value
localStorage.setItem(props.name, endValue);
setValue(endValue);
EventBus.emit('refresh', props.category);
}
};
animationRef.current = requestAnimationFrame(animate);
toast(variables.getMessage('toasts.reset'));
}, [handleChange, props.default]);
}, [value, props]);
const percentage =
((Number(value) - Number(props.min)) / (Number(props.max) - Number(props.min))) * 100;
return (
<>
<span className={'sliderTitle'}>
{props.title}
<span>{Number(value)}</span>
<span className="link" onClick={resetItem}>
<div className="slider-container">
<div className="slider-header">
<span className="slider-value">{Number(value)}</span>
<span className="slider-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</span>
<Slider
value={Number(value)}
onChange={handleChange}
valueLabelDisplay="auto"
default={Number(props.default)}
min={Number(props.min)}
max={Number(props.max)}
step={Number(props.step) || 1}
getAriaValueText={(value) => `${value}`}
marks={props.marks || []}
/>
</>
</div>
<div className="slider-wrapper">
<input
type="range"
className="slider-input"
value={Number(value)}
onChange={handleChange}
min={Number(props.min)}
max={Number(props.max)}
step={Number(props.step) || 1}
style={{ '--slider-percentage': `${percentage}%` }}
aria-label={props.title}
aria-valuemin={Number(props.min)}
aria-valuemax={Number(props.max)}
aria-valuenow={Number(value)}
disabled={props.disabled || false}
/>
{props.marks && props.marks.length > 0 && (
<div className="slider-marks">
{props.marks.map((mark) => (
<span
key={mark.value}
className="slider-mark"
style={{
left: `${((mark.value - Number(props.min)) / (Number(props.max) - Number(props.min))) * 100}%`,
}}
>
{mark.label}
</span>
))}
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,174 @@
@use 'scss/variables' as *;
.slider-container {
width: 300px;
margin-bottom: 30px;
.slider-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.slider-value {
font-size: 16px;
font-weight: 600;
@include themed {
color: t($color);
}
}
.slider-reset {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($link);
}
&:hover {
opacity: 0.8;
}
svg {
font-size: 12px;
}
}
.slider-wrapper {
position: relative;
width: 100%;
}
.slider-input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
outline: none;
cursor: pointer;
transition: background 0.3s ease;
@include themed {
background: linear-gradient(
to right,
t($link) 0%,
t($link) var(--slider-percentage),
t($modal-sidebarActive) var(--slider-percentage),
t($modal-sidebarActive) 100%
);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
@include themed {
background: t($color);
border: 2px solid t($link);
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
}
}
&::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
border: none;
@include themed {
background: t($color);
border: 2px solid t($link);
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
}
}
&:focus-visible {
outline: none;
&::-webkit-slider-thumb {
@include themed {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
}
transform: scale(1.15);
}
&::-moz-range-thumb {
@include themed {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
}
transform: scale(1.15);
}
}
&:active:not(:disabled) {
&::-webkit-slider-thumb {
transform: scale(1.2);
}
&::-moz-range-thumb {
transform: scale(1.2);
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&::-webkit-slider-thumb {
cursor: not-allowed;
transform: scale(1) !important;
}
&::-moz-range-thumb {
cursor: not-allowed;
transform: scale(1) !important;
}
}
}
.slider-marks {
position: relative;
width: 100%;
height: 20px;
margin-top: 8px;
.slider-mark {
position: absolute;
transform: translateX(-50%);
font-size: 12px;
@include themed {
color: t($subColor);
}
}
}
}

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,110 @@
@use 'scss/variables' as *;
.text-field-container {
display: flex;
flex-direction: column;
width: 300px;
margin-top: 10px;
}
.text-field {
display: flex;
flex-direction: column;
gap: 8px;
.text-field-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.text-field-label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
}
}
.text-field-reset {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($link);
}
&:hover {
opacity: 0.8;
}
svg {
font-size: 12px;
}
}
.text-field-input {
height: 56px;
padding: 0 16px;
font-size: 16px;
outline: none;
transition: 0.2s ease;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover,
&:focus {
border-color: t($color);
}
&::placeholder {
color: t($subColor);
}
}
}
.text-field-textarea {
padding: 16px;
font-size: 16px;
outline: none;
resize: vertical;
min-height: 100px;
transition: 0.2s ease;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover,
&:focus {
border-color: t($color);
}
&::placeholder {
color: t($subColor);
}
}
}
&.customcss .text-field-textarea {
font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console',
'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono',
'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace !important;
}
}

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

@@ -3,6 +3,8 @@ export * from './ChipSelect';
export * from './Dropdown';
export * from './FileUpload';
export * from './Radio';
export * from './SearchInput';
export * from './Slider';
export * from './Switch';
export * from './Text';
export * from './Textarea';

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.0';

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);
@@ -172,12 +172,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 +191,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,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

@@ -1,32 +1,17 @@
{
"Ryan Hutton": {
"photo": [2]
"photo": [1]
},
"Francesco De Tommaso": {
"photo": [3]
"photo": [2]
},
"Andy Vu": {
"photo": [4]
},
"Kevin Kurek": {
"photo": [5]
},
"Frank Mckenna": {
"photo": [6]
},
"Yuriy Bogdanov": {
"photo": [7]
"photo": [3]
},
"Sergi Ferrete": {
"photo": [9]
},
"Marcin Czerniawski": {
"photo": [10]
},
"Thomas Lipke": {
"photo": [11]
"photo": [4]
},
"Pixabay": {
"photo": [1, 8, 12]
"photo": [5]
}
}

View File

@@ -8,35 +8,82 @@ import {
MdPersonalVideo,
MdOutlineFileUpload,
MdFolder,
MdChevronLeft,
MdChevronRight,
MdDelete,
MdInfo,
} from 'react-icons/md';
import EventBus from 'utils/eventbus';
import { compressAccurately, filetoDataURL } from 'image-conversion';
import videoCheck from '../api/videoCheck';
import {
getAllBackgrounds,
getAllBackgroundsWithMetadata,
addBackground,
updateBackground,
deleteBackground,
clearAllBackgrounds,
deleteMultipleBackgrounds,
migrateFromLocalStorage,
updateBackgroundMetadata,
} from 'utils/customBackgroundDB';
import {
getImageDimensions,
generateBlurHash,
getDataUrlSize,
getFileName,
calculateStorageSize,
calculateTotalStorageSize,
formatBytes,
} from 'utils/imageMetadata';
import { generateBlurHashDataUrl } from '../api/blurHash';
import { Checkbox, FileUpload } from 'components/Form/Settings';
import { Checkbox, FileUpload, Dropdown } from 'components/Form/Settings';
import { Tooltip, Button } from 'components/Elements';
import Modal from 'react-modal';
import CustomURLModal from './CustomURLModal';
import FolderTaggingModal from './FolderTaggingModal';
const CustomSettings = memo(() => {
const [customBackground, setCustomBackground] = useState([]);
const [customURLModal, setCustomURLModal] = useState(false);
const [folderTaggingModal, setFolderTaggingModal] = useState(false);
const [pendingFiles, setPendingFiles] = useState([]);
const [urlError, setUrlError] = useState('');
const [currentBackgroundIndex, setCurrentBackgroundIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState({ current: 0, total: 0 });
const [isDragging, setIsDragging] = useState(false);
const [selectedImages, setSelectedImages] = useState(new Set());
const [sortBy, setSortBy] = useState(localStorage.getItem('customImageSort') || 'date_desc');
const [storageQuotaModal, setStorageQuotaModal] = useState(false);
const [storageQuota, setStorageQuota] = useState({ usage: 0, quota: 0 });
const customDnd = useRef(null);
const dragCounter = useRef(0);
// IndexedDB typically has 50MB+ quota, we'll check dynamically
const FALLBACK_STORAGE_LIMIT = 50000000; // 50MB fallback if API unavailable
// Fetch storage quota
useEffect(() => {
const fetchQuota = async () => {
if (navigator.storage && navigator.storage.estimate) {
try {
const estimate = await navigator.storage.estimate();
setStorageQuota({
usage: estimate.usage || 0,
quota: estimate.quota || FALLBACK_STORAGE_LIMIT,
});
} catch (error) {
console.warn('Could not get storage estimate:', error);
setStorageQuota({ usage: 0, quota: FALLBACK_STORAGE_LIMIT });
}
} else {
setStorageQuota({ usage: 0, quota: FALLBACK_STORAGE_LIMIT });
}
};
fetchQuota();
}, [customBackground]);
// Load backgrounds from IndexedDB on mount
useEffect(() => {
const loadBackgrounds = async () => {
@@ -45,8 +92,24 @@ const CustomSettings = memo(() => {
await migrateFromLocalStorage();
// Load from IndexedDB
const backgrounds = await getAllBackgrounds();
const backgrounds = await getAllBackgroundsWithMetadata();
setCustomBackground(backgrounds);
// Backfill missing metadata for existing images
backgrounds.forEach(async (bg) => {
if (!bg.dimensions && bg.url && !videoCheck(bg.url)) {
try {
const dimensions = await getImageDimensions(bg.url);
const blurHash = await generateBlurHash(bg.url);
await updateBackgroundMetadata(bg.id, { dimensions, blurHash });
// Reload backgrounds to show updated metadata
const updatedBackgrounds = await getAllBackgroundsWithMetadata();
setCustomBackground(updatedBackgrounds);
} catch (error) {
console.warn('Could not extract metadata for existing image:', error);
}
}
});
} catch (error) {
console.error('Error loading backgrounds:', error);
toast(variables.getMessage('toasts.error'));
@@ -59,44 +122,159 @@ const CustomSettings = memo(() => {
}, []);
const handleCustomBackground = useCallback(
async (e, index) => {
const result = e.target.result;
async (file, dataUrl, metadata, skipRefresh = false) => {
try {
// Update or add to IndexedDB
if (index < customBackground.length) {
await updateBackground(index, result);
} else {
await addBackground(result);
}
const backgroundData = {
url: dataUrl,
name: metadata.name,
uploadDate: Date.now(),
dimensions: metadata.dimensions,
fileSize: metadata.fileSize,
folder: metadata.folder || '',
blurHash: metadata.blurHash,
};
// Reload from IndexedDB to get the latest state
const backgrounds = await getAllBackgrounds();
await addBackground(backgroundData);
// Reload from IndexedDB to get the latest state and update React state
const backgrounds = await getAllBackgroundsWithMetadata();
setCustomBackground(backgrounds);
// Store count in localStorage for backward compatibility
try {
localStorage.setItem('customBackground', JSON.stringify(backgrounds));
localStorage.setItem('customBackground', JSON.stringify(backgrounds.map((bg) => bg.url)));
} catch (_quotaError) {
// If quota exceeded, just store the count
console.warn('localStorage quota exceeded, storing count only');
localStorage.setItem('customBackgroundCount', backgrounds.length.toString());
}
const reminderInfo = document.querySelector('.reminder-info');
if (reminderInfo) {
reminderInfo.style.display = 'flex';
// Only emit refresh if not part of a batch upload
if (!skipRefresh) {
EventBus.emit('refresh', 'background');
}
localStorage.setItem('showReminder', true);
EventBus.emit('refresh', 'background');
} catch (error) {
console.error('Error saving background:', error);
toast(variables.getMessage('toasts.error'));
}
},
[customBackground.length],
[],
);
const processImageFile = async (file, folderName = '') => {
// Calculate actual storage from existing backgrounds
const storageSize = customBackground.reduce((total, bg) => {
if (bg.url && bg.url.startsWith('data:')) {
return total + getDataUrlSize(bg.url);
}
return total;
}, 0);
const availableQuota = storageQuota.quota || FALLBACK_STORAGE_LIMIT;
// Request persistent storage if approaching limit (90%)
if (storageSize / availableQuota > 0.9 && navigator.storage && navigator.storage.persist) {
try {
const isPersisted = await navigator.storage.persist();
if (isPersisted) {
console.log('Storage persistence granted');
}
} catch (error) {
console.warn('Could not request storage persistence:', error);
}
}
if (videoCheck(file.type)) {
if (storageSize + file.size > availableQuota) {
throw new Error('no_storage');
}
const reader = new FileReader();
return new Promise((resolve, reject) => {
reader.onloadend = () => {
resolve({
dataUrl: reader.result,
metadata: {
name: getFileName(file, customBackground.length),
dimensions: null,
fileSize: file.size,
folder: folderName,
blurHash: null,
},
});
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
} else {
// Compress image
const compressed = await compressAccurately(file, {
size: 450,
accuracy: 0.9,
});
const availableQuota = storageQuota.quota || FALLBACK_STORAGE_LIMIT;
if (storageSize + compressed.size > availableQuota) {
throw new Error('no_storage');
}
const dataUrl = await filetoDataURL(compressed);
// Generate metadata in parallel
const [dimensions, blurHash] = await Promise.all([
getImageDimensions(dataUrl),
generateBlurHash(dataUrl).catch(() => null), // Don't fail if blur hash fails
]);
return {
dataUrl,
metadata: {
name: getFileName(file, customBackground.length),
dimensions,
fileSize: getDataUrlSize(dataUrl),
folder: folderName,
blurHash,
},
};
}
};
const handleBatchUpload = async (files, folderName = '') => {
setIsUploading(true);
setUploadProgress({ current: 0, total: files.length });
const errors = [];
for (let i = 0; i < files.length; i++) {
try {
const result = await processImageFile(files[i], folderName);
// Skip refresh during batch upload to prevent background flashing
await handleCustomBackground(files[i], result.dataUrl, result.metadata, true);
setUploadProgress({ current: i + 1, total: files.length });
} catch (error) {
if (error.message === 'no_storage') {
toast(variables.getMessage('toasts.no_storage'));
break;
}
errors.push(files[i].name);
}
}
if (errors.length > 0) {
toast(variables.getMessage('toasts.error') + `: ${errors.join(', ')}`);
}
// Emit refresh once after all images are uploaded
EventBus.emit('refresh', 'background');
setIsUploading(false);
setUploadProgress({ current: 0, total: 0 });
};
const handleFolderTagging = async (folderName) => {
setFolderTaggingModal(false);
await handleBatchUpload(pendingFiles, folderName);
setPendingFiles([]);
};
const modifyCustomBackground = useCallback(async (type, index) => {
try {
if (type === 'add') {
@@ -106,22 +284,17 @@ const CustomSettings = memo(() => {
}
// Reload from IndexedDB to get the latest state
const backgrounds = await getAllBackgrounds();
const backgrounds = await getAllBackgroundsWithMetadata();
setCustomBackground(backgrounds);
// Store in localStorage with quota handling
try {
localStorage.setItem('customBackground', JSON.stringify(backgrounds));
localStorage.setItem('customBackground', JSON.stringify(backgrounds.map((bg) => bg.url)));
} catch (_quotaError) {
console.warn('localStorage quota exceeded, storing count only');
localStorage.setItem('customBackgroundCount', backgrounds.length.toString());
}
const reminderInfo = document.querySelector('.reminder-info');
if (reminderInfo) {
reminderInfo.style.display = 'flex';
}
localStorage.setItem('showReminder', true);
EventBus.emit('refresh', 'background');
} catch (error) {
console.error('Error modifying background:', error);
@@ -129,12 +302,53 @@ const CustomSettings = memo(() => {
}
}, []);
const handleBatchDelete = async () => {
if (selectedImages.size === 0) {
return;
}
try {
const indices = Array.from(selectedImages).sort((a, b) => b - a);
await deleteMultipleBackgrounds(indices);
// Reload from IndexedDB
const backgrounds = await getAllBackgroundsWithMetadata();
setCustomBackground(backgrounds);
setSelectedImages(new Set());
// Update localStorage
try {
localStorage.setItem('customBackground', JSON.stringify(backgrounds.map((bg) => bg.url)));
} catch (_quotaError) {
localStorage.setItem('customBackgroundCount', backgrounds.length.toString());
}
EventBus.emit('refresh', 'background');
toast(variables.getMessage('toasts.deleted'));
} catch (error) {
console.error('Error batch deleting:', error);
toast(variables.getMessage('toasts.error'));
}
};
const toggleImageSelection = (index) => {
const newSelection = new Set(selectedImages);
if (newSelection.has(index)) {
newSelection.delete(index);
} else {
newSelection.add(index);
}
setSelectedImages(newSelection);
};
const handleSort = (sortOption) => {
setSortBy(sortOption);
localStorage.setItem('customImageSort', sortOption);
};
const uploadCustomBackground = useCallback(() => {
const newIndex = customBackground.length;
document.getElementById('bg-input').setAttribute('index', newIndex);
document.getElementById('bg-input').click();
setCurrentBackgroundIndex(newIndex);
}, [customBackground.length]);
}, []);
const addCustomURL = useCallback(
async (e) => {
@@ -145,14 +359,99 @@ const CustomSettings = memo(() => {
return setUrlError(variables.getMessage('widgets.quicklinks.url_error'));
}
const newIndex = customBackground.length;
setCustomURLModal(false);
setCurrentBackgroundIndex(newIndex);
await handleCustomBackground({ target: { result: e } }, newIndex);
try {
// Extract filename from URL
const urlParts = e.split('/');
const filename = urlParts[urlParts.length - 1].split('?')[0] || 'Remote Image';
// Try to extract metadata from the remote image
let dimensions = null;
let blurHash = null;
try {
dimensions = await getImageDimensions(e);
blurHash = await generateBlurHash(e);
} catch (metadataError) {
console.warn('Could not extract metadata from remote image:', metadataError);
}
const backgroundData = {
url: e,
name: filename,
uploadDate: Date.now(),
dimensions,
fileSize: null, // Cannot determine file size for remote URLs without fetching
folder: '',
blurHash,
};
await addBackground(backgroundData);
const backgrounds = await getAllBackgroundsWithMetadata();
setCustomBackground(backgrounds);
} catch (error) {
console.error('Error adding URL:', error);
toast(variables.getMessage('toasts.error'));
}
try {
localStorage.setItem(
'customBackground',
JSON.stringify(updatedBackgrounds.map((bg) => bg.url)),
);
} catch (_quotaError) {
localStorage.setItem('customBackgroundCount', updatedBackgrounds.length.toString());
}
EventBus.emit('refresh', 'background');
},
[customBackground.length, handleCustomBackground],
[customBackground.length],
);
const handleFileInputChange = async (files) => {
if (files.length > 1) {
// Multiple files - show tagging modal
setPendingFiles(files);
setFolderTaggingModal(true);
} else {
// Single file - upload directly
await handleBatchUpload(files, '');
}
};
// Sorted backgrounds
const sortedBackgrounds = [...customBackground].sort((a, b) => {
switch (sortBy) {
case 'date_asc':
return a.uploadDate - b.uploadDate;
case 'date_desc':
return b.uploadDate - a.uploadDate;
case 'name_asc':
return (a.name || '').localeCompare(b.name || '');
case 'name_desc':
return (b.name || '').localeCompare(a.name || '');
case 'size_asc':
return (a.fileSize || 0) - (b.fileSize || 0);
case 'size_desc':
return (b.fileSize || 0) - (a.fileSize || 0);
default:
return 0;
}
});
// Calculate storage usage from actual background data
const storageUsed = customBackground.reduce((total, bg) => {
// Calculate size of the data URL
if (bg.url && bg.url.startsWith('data:')) {
return total + getDataUrlSize(bg.url);
}
return total;
}, 0);
const availableStorageLimit = storageQuota.quota || FALLBACK_STORAGE_LIMIT;
const storagePercent = (storageUsed / availableStorageLimit) * 100;
const totalStorageUsed = calculateTotalStorageSize();
const TOTAL_STORAGE_LIMIT = 5242880; // 5MB total localStorage limit (browser default)
useEffect(() => {
const dnd = customDnd.current;
if (!dnd) return;
@@ -166,10 +465,8 @@ const CustomSettings = memo(() => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
console.log('Drag enter, counter:', dragCounter.current);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
console.log('Setting isDragging to true');
}
};
@@ -182,61 +479,26 @@ const CustomSettings = memo(() => {
}
};
const handleDrop = (e) => {
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;
const files = Array.from(e.dataTransfer.files);
const settings = {};
Object.keys(localStorage).forEach((key) => {
settings[key] = localStorage.getItem(key);
});
if (files.length === 0) {
return;
}
const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length;
// Process each dropped file
files.forEach((file, index) => {
const fileIndex = customBackground.length + index;
if (videoCheck(file.type) === true) {
if (settingsSize + file.size > 4850000) {
return toast(variables.getMessage('toasts.no_storage'));
}
const reader = new FileReader();
reader.onloadend = () => {
handleCustomBackground({ target: { result: reader.result } }, fileIndex);
};
reader.readAsDataURL(file);
} else {
// Handle image files
compressAccurately(file, {
size: 450,
accuracy: 0.9,
})
.then(async (res) => {
if (settingsSize + res.size > 4850000) {
return toast(variables.getMessage('toasts.no_storage'));
}
handleCustomBackground(
{
target: {
result: await filetoDataURL(res),
},
},
fileIndex,
);
})
.catch((error) => {
console.error('Error compressing image:', error);
toast(variables.getMessage('toasts.error'));
});
}
});
if (files.length > 1) {
// Multiple files - show tagging modal
setPendingFiles(files);
setFolderTaggingModal(true);
} else {
// Single file - upload directly
await handleBatchUpload(files, '');
}
};
dnd.ondragover = handleDragOver;
@@ -252,14 +514,36 @@ const CustomSettings = memo(() => {
dnd.ondrop = null;
}
};
}, [customBackground.length, handleCustomBackground]);
}, [customBackground.length, handleBatchUpload]);
const hasVideo = customBackground.filter((bg) => bg && videoCheck(bg)).length > 0;
const hasVideo = sortedBackgrounds.filter((bg) => bg && videoCheck(bg.url)).length > 0;
if (isLoading) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<span>{variables.getMessage('modals.main.loading')}</span>
<div className="photosEmpty">
<div className="loaderHolder">
<div id="loader"></div>
<span className="subtitle">{variables.getMessage('modals.main.loading')}</span>
</div>
</div>
);
}
if (isUploading) {
return (
<div className="photosEmpty">
<div className="loaderHolder">
<div id="loader"></div>
<span className="subtitle">
{variables.getMessage('modals.main.settings.sections.background.source.uploading', {
current: uploadProgress.current,
total: uploadProgress.total,
})}
</span>
<span className="subtitle">
{Math.round((uploadProgress.current / uploadProgress.total) * 100)}%
</span>
</div>
</div>
);
}
@@ -281,7 +565,7 @@ const CustomSettings = memo(() => {
}
>
<div className="imagesTopBar">
<div>
<div className="imagesTopBarTitle">
<MdAddPhotoAlternate />
<div>
<span className="title">
@@ -303,41 +587,243 @@ const CustomSettings = memo(() => {
icon={<MdOutlineFileUpload />}
label={variables.getMessage('modals.main.settings.sections.background.source.upload')}
/>
<Button
type="settings"
onClick={() => setCustomURLModal(true)}
icon={<MdAddLink />}
label={variables.getMessage(
'modals.main.settings.sections.background.source.add_url',
)}
</div>
</div>
<div className="imagesControlBar">
<div className="controlBarLeft">
<span className="image-count">
{customBackground.length} {customBackground.length === 1 ? 'image' : 'images'}
<span className="storage-info">
{' '}
· {formatBytes(storageUsed)} / {formatBytes(availableStorageLimit)}
{storagePercent > 80 && navigator.storage && navigator.storage.persist && (
<Tooltip title="Request persistent storage to prevent browser from automatically clearing your images">
<button
className="request-storage-link"
onClick={async () => {
try {
const isPersisted = await navigator.storage.persist();
if (isPersisted) {
toast(
'Persistent storage granted - your images are protected from eviction',
);
} else {
toast(
'Persistent storage denied - images may be cleared if storage is low',
);
}
} catch (error) {
console.error('Storage request error:', error);
toast('Could not request persistent storage');
}
}}
>
Protect images
</button>
</Tooltip>
)}
</span>
</span>
<span className="selection-separator">·</span>
{selectedImages.size > 0 ? (
<>
<span className="selected-count">{selectedImages.size} selected</span>
<button className="delete-link" onClick={handleBatchDelete}>
Delete
</button>
{selectedImages.size < customBackground.length && (
<button
className="select-all-link"
onClick={() => {
const allIndices = new Set(customBackground.map((_, i) => i));
setSelectedImages(allIndices);
}}
>
Select all
</button>
)}
{selectedImages.size === customBackground.length && (
<button className="select-all-link" onClick={() => setSelectedImages(new Set())}>
Deselect all
</button>
)}
</>
) : (
customBackground.length > 0 && (
<button
className="select-all-link"
onClick={() => {
const allIndices = new Set(customBackground.map((_, i) => i));
setSelectedImages(allIndices);
}}
>
Select all
</button>
)
)}
</div>
<div className="controlBarRight">
<Dropdown
name="customImageSort"
category="customImageSort"
onChange={handleSort}
items={[
{
value: 'date_desc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.date_newest',
),
},
{
value: 'date_asc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.date_oldest',
),
},
{
value: 'name_asc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.name_asc',
),
},
{
value: 'name_desc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.name_desc',
),
},
{
value: 'size_asc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.size_small',
),
},
{
value: 'size_desc',
text: variables.getMessage(
'modals.main.settings.sections.background.source.sort.size_large',
),
},
]}
/>
</div>
</div>
<div className="dropzone-content">
{customBackground.length > 0 ? (
<div className="images-row">
{customBackground.map((url, index) => (
<div key={index}>
{url && !videoCheck(url) ? (
<img alt={'Custom background ' + (index || 0)} src={customBackground[index]} />
) : url && videoCheck(url) ? (
<MdPersonalVideo className="customvideoicon" />
) : null}
{customBackground.length > 0 && (
{sortedBackgrounds.length > 0 ? (
<div className={`images-grid ${selectedImages.size > 0 ? 'selection-mode' : ''}`}>
{sortedBackgrounds.map((bg, index) => {
const originalIndex = customBackground.findIndex((item) => item === bg);
const isVideo = bg && videoCheck(bg.url);
return (
<div
key={originalIndex}
className="image-card"
onClick={(e) => {
// Only select if clicking the card itself, not navigation buttons
if (!e.target.closest('.image-nav-buttons')) {
toggleImageSelection(originalIndex);
}
}}
>
<div className="image-checkbox">
<input
type="checkbox"
checked={selectedImages.has(originalIndex)}
onChange={() => toggleImageSelection(originalIndex)}
/>
</div>
<div className="image-preview">
{bg.blurHash &&
!isVideo &&
(() => {
const blurHashDataUrl = generateBlurHashDataUrl(bg.blurHash, 32, 32);
return blurHashDataUrl ? (
<div
className="blur-placeholder"
style={{
backgroundImage: `url(${blurHashDataUrl})`,
filter: 'blur(20px)',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
}}
/>
) : null;
})()}
{isVideo ? (
<div className="video-icon-wrapper">
<MdPersonalVideo className="customvideoicon" />
</div>
) : (
<img
alt={bg.name || 'Custom background'}
src={bg.url}
loading="lazy"
style={{ position: 'relative', zIndex: 1 }}
/>
)}
<div className="image-nav-buttons">
<button
className="nav-button nav-prev"
onClick={() => {
if (index > 0) {
const prevBg = sortedBackgrounds[index - 1];
EventBus.emit('refresh', 'background', prevBg.url);
}
}}
disabled={index === 0}
>
<MdChevronLeft />
</button>
<button
className="nav-button nav-next"
onClick={() => {
if (index < sortedBackgrounds.length - 1) {
const nextBg = sortedBackgrounds[index + 1];
EventBus.emit('refresh', 'background', nextBg.url);
}
}}
disabled={index === sortedBackgrounds.length - 1}
>
<MdChevronRight />
</button>
</div>
</div>
<div className="image-metadata">
<span className="image-name" title={bg.name}>
{bg.name || 'Unnamed'}
</span>
<div className="image-details">
{bg.dimensions && (
<span className="detail">
{bg.dimensions.width} × {bg.dimensions.height}
</span>
)}
{bg.fileSize && <span className="detail">{formatBytes(bg.fileSize)}</span>}
{bg.folder && <span className="detail folder-tag">{bg.folder}</span>}
</div>
</div>
<Tooltip
title={variables.getMessage(
'modals.main.settings.sections.background.source.remove',
)}
>
<Button
type="settings"
onClick={() => modifyCustomBackground('remove', index)}
icon={<MdCancel />}
/>
<button
className="delete-button"
onClick={() => modifyCustomBackground('remove', originalIndex)}
>
<MdCancel />
</button>
</Tooltip>
)}
</div>
))}
</div>
);
})}
</div>
) : (
<div className="photosEmpty">
@@ -366,14 +852,16 @@ const CustomSettings = memo(() => {
)}
</div>
</div>
<FileUpload
id="bg-input"
accept="image/jpeg, image/png, image/webp, image/webm, image/gif, video/mp4, video/webm, video/ogg"
loadFunction={(e, fileIndex) => {
const index = currentBackgroundIndex + fileIndex;
handleCustomBackground(e, index);
multiple
loadFunction={async (files) => {
await handleFileInputChange(files);
}}
/>
{hasVideo && (
<>
<Checkbox
@@ -390,6 +878,7 @@ const CustomSettings = memo(() => {
/>
</>
)}
<Modal
closeTimeoutMS={100}
onRequestClose={() => setCustomURLModal(false)}
@@ -404,6 +893,82 @@ const CustomSettings = memo(() => {
modalCloseOnly={() => setCustomURLModal(false)}
/>
</Modal>
<Modal
closeTimeoutMS={100}
onRequestClose={() => {
setFolderTaggingModal(false);
setPendingFiles([]);
}}
isOpen={folderTaggingModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<FolderTaggingModal
files={pendingFiles}
onConfirm={handleFolderTagging}
onCancel={() => {
setFolderTaggingModal(false);
setPendingFiles([]);
}}
/>
</Modal>
<Modal
closeTimeoutMS={100}
onRequestClose={() => setStorageQuotaModal(false)}
isOpen={storageQuotaModal}
className="Modal resetmodal mainModal"
overlayClassName="Overlay resetoverlay"
ariaHideApp={false}
>
<div className="smallModal">
<div className="shareHeader">
<span className="title">
{variables.getMessage('modals.main.settings.sections.background.source.storage_info')}
</span>
<button className="closeModal" onClick={() => setStorageQuotaModal(false)}>
<MdCancel />
</button>
</div>
<div style={{ padding: '20px' }}>
<p className="subtitle">
{variables.getMessage(
'modals.main.settings.sections.background.source.storage_description',
)}
</p>
<div style={{ marginTop: '20px' }}>
<p className="subtitle" style={{ fontWeight: '600', marginBottom: '8px' }}>
Custom Backgrounds
</p>
<p className="subtitle">
{formatBytes(storageUsed)} / {formatBytes(availableStorageLimit)} (
{Math.round(storagePercent)}%)
</p>
</div>
<div style={{ marginTop: '15px' }}>
<p className="subtitle" style={{ fontWeight: '600', marginBottom: '8px' }}>
Total localStorage Usage
</p>
<p className="subtitle">
{formatBytes(totalStorageUsed)} / {formatBytes(TOTAL_STORAGE_LIMIT)} (
{Math.round((totalStorageUsed / TOTAL_STORAGE_LIMIT) * 100)}%)
</p>
<p className="subtitle" style={{ marginTop: '8px', fontSize: '12px', opacity: 0.7 }}>
Includes all Mue settings and custom images
</p>
</div>
</div>
<div className="resetFooter">
<Button
type="settings"
onClick={() => setStorageQuotaModal(false)}
label={variables.getMessage('modals.main.settings.buttons.close')}
/>
</div>
</div>
</Modal>
</>
);
});

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

@@ -10,7 +10,6 @@ import {
Section,
} from 'components/Layout/Settings';
import { Checkbox, Switch, Text } from 'components/Form/Settings';
import { TextareaAutosize } from '@mui/material';
import { Button } from 'components/Elements';
import { toast } from 'react-toastify';
@@ -192,15 +191,15 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
<span className="subtitle">
{variables.getMessage(`${GREETING_SECTION}.event_name`)}
</span>
<TextareaAutosize
<input
type="text"
className="text-field-input"
value={event.name}
placeholder={variables.getMessage(`${GREETING_SECTION}.event_name`)}
onChange={(e) => {
const updatedEvent = { ...event, name: e.target.value };
updateEvent(index, updatedEvent);
}}
varient="outlined"
style={{ padding: '0' }}
/>
</div>
</div>
@@ -212,11 +211,16 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
</label>
<input
id="day"
type="tel"
type="number"
min="1"
max="31"
value={event.date}
onChange={(e) => {
const updatedEvent = { ...event, date: parseInt(e.target.value, 10) };
updateEvent(index, updatedEvent);
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 31) {
const updatedEvent = { ...event, date: value };
updateEvent(index, updatedEvent);
}
}}
/>
<hr />
@@ -225,11 +229,16 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
</label>
<input
id="month"
type="tel"
type="number"
min="1"
max="12"
value={event.month}
onChange={(e) => {
const updatedEvent = { ...event, month: parseInt(e.target.value, 10) };
updateEvent(index, updatedEvent);
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 12) {
const updatedEvent = { ...event, month: value };
updateEvent(index, updatedEvent);
}
}}
/>
</div>

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: '48px', zIndex: 2 }}
>
<div className="item-sideload-badge">
<MdOutlineUploadFile />
</div>
</Tooltip>
)}
{isInstalled && item.colour && !isSideloaded && (
<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,13 @@ function Items({
isCurator,
type,
items,
collection,
toggleFunction,
collectionFunction,
onCollection,
filter,
moreByCreator,
showCreateYourOwn,
filterOptions = false,
onSortChange,
isAdded = false,
onUninstall,
}) {
const [selectedCategory, setSelectedCategory] = useState('all');
const [sortType, setSortType] = useState(localStorage.getItem('sortMarketplace') || 'a-z');
@@ -161,48 +173,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 +200,7 @@ function Items({
/>
</div>
)}
<div className={`items ${moreByCreator ? 'creatorItems' : ''}`}>
<div className='items'>
{items
?.filter((item) => filterItems(item, filter, filterOptions ? selectedCategory : 'all'))
.map((item, index) => (
@@ -239,29 +211,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

@@ -84,27 +84,24 @@ const Added = memo(() => {
const sortAddons = useCallback((value, sendEvent) => {
const installedItems = JSON.parse(localStorage.getItem('installed'));
switch (value) {
case 'newest':
installedItems.reverse();
break;
case 'oldest':
break;
case 'a-z':
installedItems.sort((a, b) => {
if (a.display_name < b.display_name) {
return -1;
}
if (a.display_name > b.display_name) {
return 1;
}
return 0;
const nameA = (a.display_name || a.name || '').toLowerCase();
const nameB = (b.display_name || b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
});
break;
case 'z-a':
installedItems.sort();
installedItems.reverse();
case 'recently-updated':
installedItems.sort((a, b) => {
const dateA = a.updated_at ? new Date(a.updated_at) : new Date(0);
const dateB = b.updated_at ? new Date(b.updated_at) : new Date(0);
return dateB - dateA;
});
break;
default:
break;
@@ -154,6 +151,12 @@ const Added = memo(() => {
setInstalled([]);
}, [installed]);
const handleUninstall = useCallback((type, name) => {
uninstall(type, name);
toast(variables.getMessage('toasts.uninstalled'));
setInstalled(JSON.parse(localStorage.getItem('installed')));
}, []);
useEffect(() => {
sortAddons(localStorage.getItem('sortAddons'), false);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -243,9 +246,8 @@ const Added = memo(() => {
onChange={(value) => sortAddons(value)}
items={[
{ value: 'newest', text: variables.getMessage('modals.main.addons.sort.newest') },
{ value: 'oldest', text: variables.getMessage('modals.main.addons.sort.oldest') },
{ value: 'a-z', text: variables.getMessage('modals.main.addons.sort.a_z') },
{ value: 'z-a', text: variables.getMessage('modals.main.addons.sort.z_a') },
{ value: 'recently-updated', text: 'Recently Updated' },
]}
/>
<Items
@@ -254,6 +256,7 @@ const Added = memo(() => {
filter=""
toggleFunction={(input) => toggle('item', input)}
showCreateYourOwn={false}
onUninstall={handleUninstall}
/>
</>
);

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,45 @@ import Preview from '../../helpers/preview/Preview';
import EventBus from 'utils/eventbus';
import { parseDeepLink, shouldAutoOpenModal, updateHash } from 'utils/deepLinking';
import { install } from 'utils/marketplace';
import Welcome from 'features/welcome/Welcome';
const DEFAULT_PACK_ID = '0c8a5bdebd13';
const isDefaultPackInstalled = () => {
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
return installed.some((item) => item.id === DEFAULT_PACK_ID);
};
const isDefaultPackUninstalled = () => {
const uninstalledPacks = JSON.parse(localStorage.getItem('uninstalledPacks') || '[]');
return uninstalledPacks.includes(DEFAULT_PACK_ID);
};
const tryInstallDefaultPack = async () => {
// Don't install if offline mode, already installed, or explicitly uninstalled
if (
localStorage.getItem('offlineMode') === 'true' ||
isDefaultPackInstalled() ||
isDefaultPackUninstalled()
) {
return false;
}
try {
const response = await fetch(
`${variables.constants.API_URL}/marketplace/item/${DEFAULT_PACK_ID}`,
);
const { data } = await response.json();
install(data.type, data, false, true);
return true;
} catch (e) {
console.error('Failed to install default pack:', e);
return false;
}
};
const Modals = () => {
const [mainModal, setMainModal] = useState(false);
const [updateModal, setUpdateModal] = useState(false);
@@ -60,6 +96,15 @@ const Modals = () => {
localStorage.setItem('showReminder', false);
}
// Try to install default pack if it wasn't installed during welcome (e.g., no internet)
if (localStorage.getItem('showWelcome') !== 'true') {
tryInstallDefaultPack().then((installed) => {
if (installed) {
EventBus.emit('refresh', 'quote');
}
});
}
// Listen for EventBus modal open requests
const handleModalOpen = (data) => {
if (data === 'openMainModal') {
@@ -76,9 +121,12 @@ const Modals = () => {
};
}, []);
const closeWelcome = () => {
const closeWelcome = async () => {
localStorage.setItem('showWelcome', false);
setWelcomeModal(false);
await tryInstallDefaultPack();
EventBus.emit('refresh', 'widgetsWelcomeDone');
EventBus.emit('refresh', 'widgets');
EventBus.emit('refresh', 'backgroundwelcome');

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

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

@@ -288,7 +288,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 +606,7 @@ button.quicklinks {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
padding-bottom: 50px;
}
.quicklink-wrapper .quicklinkstext {

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,15 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
return data;
};
const [quoteType, setQuoteType] = useState(localStorage.getItem('quoteType') || 'api');
const [quoteType, setQuoteType] = useState(() => {
let type = localStorage.getItem('quoteType') || 'quote_pack';
// Migrate deprecated 'api' type to 'quote_pack'
if (type === 'api') {
type = 'quote_pack';
localStorage.setItem('quoteType', 'quote_pack');
}
return type;
});
const [customQuote, setCustomQuote] = useState(getCustom());
const handleCustomQuote = (e, text, index, type) => {
@@ -93,10 +100,6 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
value: 'quote_pack',
text: variables.getMessage('modals.main.marketplace.title'),
},
{
value: 'api',
text: variables.getMessage('modals.main.settings.sections.background.type.api'),
},
{ value: 'custom', text: variables.getMessage(`${QUOTE_SECTION}.custom`) },
]}
/>
@@ -162,23 +165,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 +186,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

@@ -3,7 +3,6 @@ import { MdAutoAwesome } from 'react-icons/md';
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
import { useLocalStorageState } from 'utils/useLocalStorageState';
import { Radio, Dropdown, Checkbox } from 'components/Form/Settings';
import { TextField } from '@mui/material';
import variables from 'config/variables';
const useWeatherSettings = () => {
@@ -82,18 +81,26 @@ const WeatherOptions = () => {
<Row>
<Content title={variables.getMessage(`${WEATHER_SECTION}.location`)} />
<Action>
<TextField
label={variables.getMessage(`${WEATHER_SECTION}.location`)}
value={location}
onChange={changeLocation}
placeholder="London"
variant="outlined"
InputLabelProps={{ shrink: true }}
/>
<span className="link" onClick={getAutoLocation}>
<MdAutoAwesome />
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
</span>
<div className="text-field-container">
<div className="text-field">
<div className="text-field-header">
<label className="text-field-label">
{variables.getMessage(`${WEATHER_SECTION}.location`)}
</label>
<span className="text-field-reset" onClick={getAutoLocation}>
<MdAutoAwesome />
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
</span>
</div>
<input
type="text"
className="text-field-input"
value={location}
onChange={changeLocation}
placeholder="London"
/>
</div>
</div>
</Action>
</Row>
);

View File

@@ -1,12 +1,11 @@
import { useState, useMemo } from 'react';
import { MdOutlineOpenInNew, MdSearch } from 'react-icons/md';
import { TextField, InputAdornment } from '@mui/material';
import { MdOutlineOpenInNew } from 'react-icons/md';
import languages from '@/i18n/languages.json';
import translationPercentages from '@/i18n/translationPercentages.json';
import { useT, useTranslation } from 'contexts/TranslationContext';
import variables from 'config/variables';
import { Radio } from 'components/Form/Settings';
import { Radio, SearchInput } from 'components/Form/Settings';
import { Header, Content } from '../Layout';
function ChooseLanguage() {
@@ -107,37 +106,14 @@ function ChooseLanguage() {
{t('modals.main.settings.sections.language.use_system')} ({systemLanguage.name})
</button>
)}
<TextField
placeholder={t('modals.main.settings.sections.language.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
variant="outlined"
size="small"
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdSearch style={{ color: '#888' }} />
</InputAdornment>
),
}}
sx={{
marginBottom: 2,
'& .MuiOutlinedInput-root': {
borderRadius: '24px',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
'& fieldset': {
border: 'none',
},
'&:hover fieldset': {
border: 'none',
},
'&.Mui-focused fieldset': {
border: 'none',
},
},
}}
/>
<div style={{ marginBottom: 16 }}>
<SearchInput
placeholder={t('modals.main.settings.sections.language.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
fullWidth
/>
</div>
<div className="languageSettings">
<Radio name="language" options={filteredLanguages} category="welcomeLanguage" />
</div>

View File

@@ -267,7 +267,25 @@
},
"custom_title": "الصور المخصصة",
"custom_description": "اختر الصور من جهازك المحلي",
"remove": "إزالة الصورة"
"remove": "إزالة الصورة",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "العرض",
"display_subtitle": "تغيير طريقة تحميل الخلفية ومعلومات الصور",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "إعادة تعيين",
"import": "استيراد",
"export": "تصدير"
"export": "تصدير",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "ترتيب",
"newest": "المثبتة (الأحدث)",
"oldest": "المثبتة (الأقدم)",
"a_z": "أبجدي (أ-ي)",
"z_a": "أبجدي (ي-أ)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "إنشاء",

View File

@@ -275,7 +275,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -526,7 +544,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -608,9 +629,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Xüsusi Şəkillər",
"custom_description": "Kompüterinizdən şəkillər seçin",
"remove": "Şəkli Sil"
"remove": "Şəkli Sil",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Ekran",
"display_subtitle": "Fon və foto məlumatlarının necə yüklənəcəyini dəyişdirin",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Sıfırla",
"import": "İdxal et",
"export": "İxrac et"
"export": "İxrac et",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sıralama",
"newest": "Quraşdırılmış (Ən yeni)",
"oldest": "Quraşdırılmış (Ən köhnə)",
"a_z": "Əlifba sırası (A-Z)",
"z_a": "Əlifba sırası (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Yarat",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Xüsusi Şəkillər",
"custom_description": "Yerel kompüterinizdən şəkilləri seçin",
"remove": "Şəkili Sil"
"remove": "Şəkili Sil",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Göstərmə",
"display_subtitle": "Arxa fon və foto məlumatının necə yükləndiyini dəyişdirin",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Sıfırla",
"import": "İdxal et",
"export": "İxrac et"
"export": "İxrac et",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sırala",
"newest": "Quraşdırılmış (Ən Yeni)",
"oldest": "Quraşdırılmış (Ən Köhnə)",
"a_z": "Əlifba sırası (A-Z)",
"z_a": "Əlifba sırası (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Yarat",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "কাস্টম ছবি",
"custom_description": "আপনার স্থানীয় কম্পিউটার থেকে ছবি নির্বাচন করুন",
"remove": "ছবি সরান"
"remove": "ছবি সরান",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "প্রদর্শন",
"display_subtitle": "পটভূমি এবং ছবির তথ্য কিভাবে লোড হয় তা পরিবর্তন করুন",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Eigene Bilder",
"custom_description": "Wählen Sie Bilder von Ihrem lokalen Computer",
"remove": "Bild entfernen"
"remove": "Bild entfernen",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Anzeige",
"display_subtitle": "Ändern Sie, wie Hintergrund- und Fotodaten geladen werden",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Zurücksetzen",
"import": "Importieren",
"export": "Exportieren"
"export": "Exportieren",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sortieren",
"newest": "Installiert (Neueste)",
"oldest": "Installiert (Älteste)",
"a_z": "Alphabetisch (A-Z)",
"z_a": "Alphabetisch (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Erstellen",

View File

@@ -275,7 +275,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -526,7 +544,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -608,9 +629,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -599,10 +620,9 @@
},
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"newest": "Recently Added",
"a_z": "Name (A-Z)",
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -275,7 +275,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -526,7 +544,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -608,9 +629,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Imágenes personalizadas",
"custom_description": "Selecciona imágenes de tu ordenador",
"remove": "Eliminar imagen"
"remove": "Eliminar imagen",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Mostrar",
"display_subtitle": "Cambiar cómo se carga la información de fondo y de la foto",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Reiniciar",
"import": "Importar",
"export": "Exportar"
"export": "Exportar",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Ordenar",
"newest": "Instalado (Nuevos)",
"oldest": "Instalado (Antiguos)",
"a_z": "Alfabético (A-Z)",
"z_a": "Alfabético (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Crear",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Reiniciar",
"import": "Importar",
"export": "Exportar"
"export": "Exportar",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Ordenar",
"newest": "Instalados (Nuevos)",
"oldest": "Instalados (Antiguos)",
"a_z": "Alfabético (A-Z)",
"z_a": "Alfabético (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Crear",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Kohandatud pildid",
"custom_description": "Vali pildid oma arvutist",
"remove": "Eemalda pilt"
"remove": "Eemalda pilt",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Kuva",
"display_subtitle": "Muuda tausta ja foto teabe laadimise viisi",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Lähtesta",
"import": "Impordi",
"export": "Ekspordi"
"export": "Ekspordi",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sorteeri",
"newest": "Installitud (uusimad)",
"oldest": "Installitud (vanimad)",
"a_z": "Tähestikuline (A-Z)",
"z_a": "Tähestikuline (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Loo",

View File

@@ -275,7 +275,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -526,7 +544,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -608,9 +629,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Images personnalisées",
"custom_description": "Sélectionnez des images depuis votre ordinateur local",
"remove": "Supprimer l'image"
"remove": "Supprimer l'image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Afficher",
"display_subtitle": "Modifier la façon dont le fond d'écran et les informations photo sont chargés",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Réinitialiser",
"import": "Importer",
"export": "Exporter"
"export": "Exporter",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Trier",
"newest": "Installé (plus récent)",
"oldest": "Installé (plus ancien)",
"a_z": "Alphabétique (A-Z)",
"z_a": "Alphabétique (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Créer",

View File

@@ -275,7 +275,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -526,7 +544,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -608,9 +629,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Reset",
"import": "Impor",
"export": "Ekspor"
"export": "Ekspor",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Urutkan",
"newest": "Terinstal (Terbaru)",
"oldest": "Terinstal (Terlama)",
"a_z": "Abjad (A-Z)",
"z_a": "Abjad (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Buat",

View File

@@ -275,7 +275,25 @@
},
"custom_title": "Custom Images",
"custom_description": "Select images from your local computer",
"remove": "Remove Image"
"remove": "Remove Image",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Display",
"display_subtitle": "Change how background and photo information are loaded",
@@ -526,7 +544,10 @@
"buttons": {
"reset": "Reset",
"import": "Import",
"export": "Export"
"export": "Export",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -608,9 +629,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Pasirinktiniai paveikslėliai",
"custom_description": "Pasirinkite paveikslėlius iš savo kompiuterio",
"remove": "Pašalinti paveikslėlį"
"remove": "Pašalinti paveikslėlį",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Rodymas",
"display_subtitle": "Keisti, kaip įkeliamas fonas ir nuotraukos informacija",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Atstatyti",
"import": "Importuoti",
"export": "Eksportuoti"
"export": "Eksportuoti",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Rūšiuoti",
"newest": "Įdiegta (Naujausi)",
"oldest": "Įdiegta (Seniausi)",
"a_z": "Abėcėlės tvarka (A-Ž)",
"z_a": "Abėcėlės tvarka (Ž-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Kurti",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Pielāgoti attēli",
"custom_description": "Izvēlieties attēlus no sava datora",
"remove": "Noņemt attēlu"
"remove": "Noņemt attēlu",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Attēlošana",
"display_subtitle": "Mainīt, kā tiek ielādēts fons un fotogrāfijas informācija",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Atiestatīt",
"import": "Importēt",
"export": "Eksportēt"
"export": "Eksportēt",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Kārtot",
"newest": "Instalēts (Jaunākais)",
"oldest": "Instalēts (Vecākais)",
"a_z": "Alfabētiski (A-Z)",
"z_a": "Alfabētiski (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Izveidot",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Aangepaste Afbeeldingen",
"custom_description": "Selecteer afbeeldingen van je lokale bestanden",
"remove": "Verwijder Afbeelding"
"remove": "Verwijder Afbeelding",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Weergave",
"display_subtitle": "Verander hoe achtergrond- en foto informatie geladen wordt",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Herstellen",
"import": "Importeren",
"export": "Exporteren"
"export": "Exporteren",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sort",
"newest": "Installed (Newest)",
"oldest": "Installed (Oldest)",
"a_z": "Alphabetical (A-Z)",
"z_a": "Alphabetical (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Create",

Some files were not shown because too many files have changed in this diff Show More