Compare commits
10 Commits
7.5.0
...
main-workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bcb7f735d | ||
|
|
62689e5d10 | ||
|
|
124d1be1a4 | ||
|
|
8265459ff8 | ||
|
|
a44de6f15b | ||
|
|
0e6eb75f8d | ||
|
|
e5d8bfec0e | ||
|
|
bc9cf3c11e | ||
|
|
896816c185 | ||
|
|
6d209e10fb |
6
.github/CODEOWNERS
vendored
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
57
.github/workflows/submit-beta.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Submit to Browser Stores (Beta)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [prereleased]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Pre-release tag to re-submit (e.g. v7.6.1-beta.3)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
submit-beta:
|
||||
runs-on: ubuntu-latest
|
||||
environment: beta
|
||||
|
||||
steps:
|
||||
- name: Resolve tag and version
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download release ZIPs
|
||||
run: |
|
||||
mkdir -p dist
|
||||
gh release download "${{ steps.tag.outputs.tag }}" \
|
||||
--pattern "chrome-*.zip" \
|
||||
--dir ./dist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Verify ZIP
|
||||
run: |
|
||||
ls -la dist/
|
||||
test -n "$(ls dist/chrome-*.zip 2>/dev/null)" || \
|
||||
{ echo "chrome ZIP not found in dist/"; exit 1; }
|
||||
|
||||
- name: Submit to Chrome Web Store (Trusted Testers)
|
||||
uses: PlasmoHQ/bpp@v3
|
||||
with:
|
||||
keys: ${{ secrets.SUBMIT_KEYS_BETA }}
|
||||
chrome-zip: dist/chrome-${{ steps.tag.outputs.version }}.zip
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Beta Store Submission: ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Chrome Web Store (trustedTesters)" >> $GITHUB_STEP_SUMMARY
|
||||
56
.github/workflows/submit.yml
vendored
@@ -1,27 +1,57 @@
|
||||
name: Submit
|
||||
name: Submit to Browser Stores
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to submit, i.e 6.0.5"
|
||||
description: "Release tag to re-submit (e.g. v7.6.1)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
submit:
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- name: Setup Chrome
|
||||
uses: browser-actions/setup-chrome@latest
|
||||
with:
|
||||
chrome-version: latest
|
||||
- name: Download Github Release Assets
|
||||
uses: PlasmoHQ/download-release-asset@v1.0.0
|
||||
with:
|
||||
tag: ${{ github.event.inputs.tag }}
|
||||
- name: Browser Plugin Publish
|
||||
uses: PlasmoHQ/bpp@v2
|
||||
- name: Resolve tag and version
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download release ZIPs
|
||||
run: |
|
||||
mkdir -p dist
|
||||
gh release download "${{ steps.tag.outputs.tag }}" \
|
||||
--pattern "chrome-*.zip" \
|
||||
--dir ./dist
|
||||
env:
|
||||
PUPPETEER_EXECUTABLE_PATH: /opt/hostedtoolcache/chromium/latest/x64/chrome
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Verify ZIP
|
||||
run: |
|
||||
ls -la dist/
|
||||
test -f "dist/chrome-${{ steps.tag.outputs.version }}.zip" || \
|
||||
{ echo "chrome ZIP not found"; exit 1; }
|
||||
|
||||
- name: Submit to Chrome Web Store
|
||||
uses: PlasmoHQ/bpp@v3
|
||||
with:
|
||||
keys: ${{ secrets.SUBMIT_KEYS }}
|
||||
chrome-zip: dist/chrome-${{ steps.tag.outputs.version }}.zip
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Store Submission: ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Chrome Web Store (listed)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
136
.github/workflows/version-bump.yml
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
name: Version Bump
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
labels:
|
||||
- 'release'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.event.pull_request.merged == true
|
||||
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: Extract bump info from PR
|
||||
id: bump_info
|
||||
run: |
|
||||
BUMP_TYPE=$(echo "${{ github.event.pull_request.body }}" | grep -oP "bump: \K(major|minor|patch)" || echo "patch")
|
||||
IS_RELEASE=$(echo "${{ github.event.pull_request.body }}" | grep -q "is_release: true" && echo "true" || echo "false")
|
||||
|
||||
echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
|
||||
echo "is_release=$IS_RELEASE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Calculate new version
|
||||
id: version
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').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]}"
|
||||
|
||||
case "${{ steps.bump_info.outputs.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}"
|
||||
|
||||
# Add pre-release suffix if NOT a production release AND NOT on main branch
|
||||
if [ "${{ steps.bump_info.outputs.is_release }}" != "true" ] && [ "${{ github.ref_name }}" != "main" ]; then
|
||||
BETA_COUNT=$(git tag -l "v${NEW_VERSION}-beta.*" | wc -l)
|
||||
BETA_NUM=$((BETA_COUNT + 1))
|
||||
NEW_VERSION="${NEW_VERSION}-beta.${BETA_NUM}"
|
||||
fi
|
||||
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- 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: bun x json -I -f manifest/chrome.json -e "this.version='${{ steps.version.outputs.new_version }}'"
|
||||
|
||||
- name: Update Firefox manifest
|
||||
run: bun x json -I -f manifest/firefox.json -e "this.version='${{ steps.version.outputs.new_version }}'"
|
||||
|
||||
- name: Update Safari manifest
|
||||
run: bun x json -I -f safari/Mue\ Extension/Resources/manifest.json -e "this.version='${{ steps.version.outputs.new_version }}'"
|
||||
|
||||
- name: Update Safari Xcode (4 MARKETING_VERSION entries)
|
||||
run: sed -i '' "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${{ steps.version.outputs.new_version }}/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 and tag
|
||||
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 }}"
|
||||
git push origin ${{ github.ref_name }}
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
- name: Auto back-merge (main only)
|
||||
if: github.ref_name == 'main'
|
||||
run: |
|
||||
git fetch origin
|
||||
git checkout beta
|
||||
git merge --no-ff origin/main -m "chore: back-merge main into beta"
|
||||
git push origin beta
|
||||
|
||||
git checkout dev
|
||||
git merge --no-ff origin/beta -m "chore: back-merge beta into dev"
|
||||
git push origin dev
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✅ Version bumped to ${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📦 Tag: v${{ steps.version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔀 Branch: ${{ github.ref_name }}" >> $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
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "### 🔄 Auto back-merge:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- main → beta ✅" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta → dev ✅" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
1
.gitignore
vendored
@@ -5,7 +5,6 @@ node_modules/
|
||||
build/
|
||||
.idea/
|
||||
dist/
|
||||
/docs
|
||||
|
||||
# Safari Extension Build Files
|
||||
safari/Mue Extension/Resources/*.html
|
||||
|
||||
310
CONTRIBUTING.md
Normal 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.
|
||||
10
README.md
@@ -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.
|
||||
|
||||
469
docs/RELEASE_PROCESS.md
Normal 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
|
||||
@@ -4,7 +4,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_name__",
|
||||
"description": "__MSG_description__",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.0",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"permissions": ["search"],
|
||||
"action": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Mue",
|
||||
"description": "Fast, open and free-to-use new tab page for modern browsers.",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.0",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"action": {
|
||||
"default_icon": "icons/128x128.png"
|
||||
|
||||
18
package.json
@@ -9,7 +9,7 @@
|
||||
"homepage": "https://muetab.com",
|
||||
"bugs": "https://github.com/mue/mue/issues/new?assignees=&labels=bug&template=bug-report.md&title=%5BBUG%5D",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.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",
|
||||
"@floating-ui/react-dom": "2.1.8",
|
||||
"@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",
|
||||
@@ -46,10 +42,10 @@
|
||||
"@commitlint/cli": "^20.3.1",
|
||||
"@commitlint/config-conventional": "^20.3.1",
|
||||
"@eartharoid/deep-merge": "^0.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"adm-zip": "0.5.16",
|
||||
"eslint": "^9.39.2",
|
||||
"adm-zip": "0.5.17",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 118 KiB |
@@ -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, ''))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -92,13 +92,20 @@ h4 {
|
||||
}
|
||||
|
||||
.imagesTopBar {
|
||||
padding-top: 25px;
|
||||
position: sticky;
|
||||
top: -20px;
|
||||
z-index: 90;
|
||||
padding: 25px 0 15px 0;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
div:nth-child(1) {
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
}
|
||||
|
||||
.imagesTopBarTitle {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
@@ -121,18 +128,139 @@ h4 {
|
||||
.topbarbuttons {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 25px;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
button:not(.MuiButtonBase-root) {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.imagesControlBar {
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
z-index: 89;
|
||||
padding: 12px 0;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
border-bottom: 1px solid t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
.controlBarLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
.image-count {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
font-weight: 400;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
.request-storage-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 13px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: #ff5c25;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selection-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.delete-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: rgb(255 71 87);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.select-all-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controlBarRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.customcss textarea {
|
||||
font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console',
|
||||
'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono',
|
||||
'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace !important;
|
||||
font-family:
|
||||
Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter',
|
||||
'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco,
|
||||
'Courier New', Courier, monospace !important;
|
||||
}
|
||||
|
||||
.preferences {
|
||||
|
||||
@@ -95,6 +95,413 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced custom images grid
|
||||
.images-grid {
|
||||
display: grid;
|
||||
padding: 1px;
|
||||
|
||||
// Show all checkboxes when in selection mode (any image selected)
|
||||
&.selection-mode {
|
||||
.image-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
|
||||
@include themed {
|
||||
.image-card {
|
||||
position: relative;
|
||||
border-radius: t($borderRadius);
|
||||
background: t($modal-secondaryColour);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: t($boxShadow);
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
outline: 3px solid #ff5c25;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.image-nav-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-checkbox {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-checkbox {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 12;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
|
||||
&:checked {
|
||||
background: #ff5c25;
|
||||
border-color: #ff5c25;
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #ff5c25;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep checkbox visible when checked
|
||||
&:has(input:checked) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: t($modal-sidebar);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-icon-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: t($modal-sidebar);
|
||||
|
||||
.customvideoicon {
|
||||
font-size: 60px;
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.blur-placeholder {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.image-nav-buttons {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
|
||||
.nav-button {
|
||||
pointer-events: all;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
svg {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-metadata {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.image-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: t($color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.image-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: t($subColor);
|
||||
|
||||
.detail {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.folder-tag {
|
||||
padding: 2px 8px;
|
||||
background: t($modal-sidebarActive);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 71, 87, 0.9);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
z-index: 11;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 71 87);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Show delete button when card is hovered or has checkbox visible
|
||||
&:hover .delete-button,
|
||||
.image-checkbox:has(input:checked) ~ * .delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Storage quota display
|
||||
.storage-quota {
|
||||
padding: 15px 20px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 50px;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-secondaryColour);
|
||||
border-top: 1px solid t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
.quota-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.quota-text {
|
||||
font-size: 13px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.quota-info-button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quota-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
}
|
||||
|
||||
.quota-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Folder tagging modal styles
|
||||
.taggingModalContent {
|
||||
padding: 20px;
|
||||
|
||||
p.subtitle {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.taggingInput {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
color: t($color);
|
||||
border-color: t($modal-sidebarActive);
|
||||
|
||||
&:focus {
|
||||
border-color: #ff5c25;
|
||||
box-shadow: 0 0 0 3px rgba(255, 92, 37, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
margin-bottom: 100px;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.photosEmpty {
|
||||
padding: 60px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.emptyNewMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overviewGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { Checkbox as CheckboxUI, FormControlLabel } from '@mui/material';
|
||||
import { MdCheck } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Checkbox.scss';
|
||||
|
||||
const Checkbox = memo((props) => {
|
||||
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
|
||||
|
||||
@@ -18,7 +20,7 @@ const Checkbox = memo((props) => {
|
||||
|
||||
variables.stats.postEvent(
|
||||
'setting',
|
||||
`${props.name} ${checked ? 'enabled' : 'disabled'}`,
|
||||
`${props.name} ${value ? 'enabled' : 'disabled'}`,
|
||||
);
|
||||
|
||||
if (props.element) {
|
||||
@@ -31,20 +33,30 @@ const Checkbox = memo((props) => {
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [checked, props]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if ((e.key === ' ' || e.key === 'Enter') && !props.disabled) {
|
||||
e.preventDefault();
|
||||
handleChange();
|
||||
}
|
||||
}, [handleChange, props.disabled]);
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<CheckboxUI
|
||||
name={props.name}
|
||||
color="primary"
|
||||
className="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={props.disabled || false}
|
||||
/>
|
||||
}
|
||||
label={props.text}
|
||||
/>
|
||||
<div className={`checkbox-wrapper ${props.disabled ? 'disabled' : ''}`}>
|
||||
<span className="checkbox-label">{props.text}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={props.disabled || false}
|
||||
className="checkbox-input"
|
||||
aria-label={props.text}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div className={`checkbox-box ${checked ? 'checked' : ''}`}>
|
||||
{checked && <MdCheck />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
118
src/components/Form/Settings/Checkbox/Checkbox.scss
Normal file
@@ -0,0 +1,118 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(checkScale) {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) .checkbox-label {
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
flex: 1;
|
||||
transition: color 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
background: t($modal-sidebar);
|
||||
color: t($color);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: t($color);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
transform: scale(0.95);
|
||||
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.checked {
|
||||
@include themed {
|
||||
background: t($link);
|
||||
border-color: t($link);
|
||||
}
|
||||
|
||||
svg {
|
||||
@include animation(checkScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:focus-visible + .checkbox-box {
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 3px t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useState, memo } from 'react';
|
||||
import { useState, memo, useRef, useEffect } from 'react';
|
||||
import { MdExpandMore, MdClose } from 'react-icons/md';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import './ChipSelect.scss';
|
||||
|
||||
function ChipSelect({ label, options, onChange }) {
|
||||
let start = (localStorage.getItem('apiCategories') || '').split(',');
|
||||
@@ -14,47 +9,88 @@ function ChipSelect({ label, options, onChange }) {
|
||||
start = [];
|
||||
}
|
||||
|
||||
const [optionsSelected, setoptionsSelected] = useState(start);
|
||||
const [optionsSelected, setOptionsSelected] = useState(start);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setoptionsSelected(typeof value === 'string' ? value.split(',') : value);
|
||||
localStorage.setItem('apiCategories', value);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleToggle = (optionName) => {
|
||||
let newSelected;
|
||||
if (optionsSelected.includes(optionName)) {
|
||||
newSelected = optionsSelected.filter((item) => item !== optionName);
|
||||
} else {
|
||||
newSelected = [...optionsSelected, optionName];
|
||||
}
|
||||
|
||||
setOptionsSelected(newSelected);
|
||||
localStorage.setItem('apiCategories', newSelected.join(','));
|
||||
|
||||
// Call parent onChange if provided
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
onChange(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveChip = (e, optionName) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(optionName);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<InputLabel id="chipSelect-label">{label}</InputLabel>
|
||||
<Select
|
||||
labelId="chipSelect-label"
|
||||
id="chipSelect"
|
||||
multiple
|
||||
value={optionsSelected}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-multiple-chip" label={label} />}
|
||||
renderValue={(optionsSelected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{optionsSelected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.name} value={option.name}>
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}{' '}
|
||||
{option.count && `(${option.count})`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="chipSelect" ref={containerRef}>
|
||||
{label && <label className="chipSelect-label">{label}</label>}
|
||||
<div className="chipSelect-control" onClick={() => setIsOpen(!isOpen)}>
|
||||
<div className="chipSelect-value">
|
||||
{optionsSelected.length === 0 ? (
|
||||
<span className="chipSelect-placeholder">Select options...</span>
|
||||
) : (
|
||||
<div className="chipSelect-chips">
|
||||
{optionsSelected.map((value) => (
|
||||
<span key={value} className="chipSelect-chip">
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
<button
|
||||
type="button"
|
||||
className="chipSelect-chip-remove"
|
||||
onClick={(e) => handleRemoveChip(e, value)}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MdExpandMore className={`chipSelect-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="chipSelect-dropdown">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
>
|
||||
<span className="chipSelect-option-checkbox">
|
||||
{optionsSelected.includes(option.name) && '✓'}
|
||||
</span>
|
||||
<span className="chipSelect-option-label">
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
|
||||
{option.count && ` (${option.count})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
178
src/components/Form/Settings/ChipSelect/ChipSelect.scss
Normal file
@@ -0,0 +1,178 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.chipSelect {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
margin-top: 10px;
|
||||
|
||||
.chipSelect-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 56px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover {
|
||||
border-color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chipSelect-placeholder {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chipSelect-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
text-transform: capitalize;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
border-radius: calc(t($borderRadius) / 2);
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
.chipSelect-chip-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-background);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: t($modal-sidebar);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
border-radius: 4px;
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected .chipSelect-option-checkbox {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
border-color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.chipSelect-option-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,165 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback, useRef } from 'react';
|
||||
import { InputLabel, MenuItem, FormControl, Select } from '@mui/material';
|
||||
import { memo, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Dropdown.scss';
|
||||
|
||||
const Dropdown = memo((props) => {
|
||||
const [value, setValue] = useState(
|
||||
localStorage.getItem(props.name) || props.items[0].value,
|
||||
localStorage.getItem(props.name) || props.items[0]?.value,
|
||||
);
|
||||
const dropdown = useRef();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const containerRef = useRef(null);
|
||||
const optionsRef = useRef([]);
|
||||
|
||||
const onChange = useCallback((e) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (newValue === variables.getMessage('modals.main.loading')) {
|
||||
return;
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
setValue(newValue);
|
||||
|
||||
if (!props.noSetting) {
|
||||
localStorage.setItem(props.name, newValue);
|
||||
localStorage.setItem(props.name2, props.value2);
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [value, props]);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newValue) => {
|
||||
if (newValue === variables.getMessage('modals.main.loading')) {
|
||||
return;
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
setValue(newValue);
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
|
||||
if (!props.noSetting) {
|
||||
localStorage.setItem(props.name, newValue);
|
||||
localStorage.setItem(props.name2, props.value2);
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
},
|
||||
[value, props],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setFocusedIndex(-1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen, props.items, props.disabled],
|
||||
);
|
||||
|
||||
const handleOptionKeyDown = useCallback(
|
||||
(e, item) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(item.value);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const resetItem = useCallback(() => {
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
onChange(defaultValue);
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [onChange, props.default, props.items]);
|
||||
|
||||
const id = 'dropdown' + props.name;
|
||||
const label = props.label || '';
|
||||
const selectedItem = props.items.find((item) => item?.value === value);
|
||||
|
||||
return (
|
||||
<FormControl fullWidth className={id}>
|
||||
<InputLabel id={id}>{label}</InputLabel>
|
||||
<Select
|
||||
labelId={id}
|
||||
id={props.name}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
ref={dropdown}
|
||||
key={id}
|
||||
<div className={`dropdown ${id} ${props.disabled ? 'disabled' : ''}`} ref={containerRef}>
|
||||
{label && (
|
||||
<div className="dropdown-header">
|
||||
<label className="dropdown-label">{label}</label>
|
||||
<span className="dropdown-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-control"
|
||||
onClick={() => !props.disabled && setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label || props.name}
|
||||
tabIndex={props.disabled ? -1 : 0}
|
||||
>
|
||||
{props.items.map((item) =>
|
||||
item !== null ? (
|
||||
<MenuItem key={id + item.value} value={item.value}>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
) : null,
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<span className="dropdown-value">{selectedItem?.text || value}</span>
|
||||
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu" role="listbox">
|
||||
{props.items.map((item, index) =>
|
||||
item !== null ? (
|
||||
<div
|
||||
key={id + item.value}
|
||||
ref={(el) => (optionsRef.current[index] = el)}
|
||||
className={`dropdown-option ${value === item.value ? 'selected' : ''} ${index === focusedIndex ? 'focused' : ''}`}
|
||||
onClick={() => onChange(item.value)}
|
||||
onKeyDown={(e) => handleOptionKeyDown(e, item)}
|
||||
role="option"
|
||||
aria-selected={value === item.value}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="dropdown-option-text">{item.text}</span>
|
||||
{value === item.value && <MdCheck className="dropdown-option-check" />}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
218
src/components/Form/Settings/Dropdown/Dropdown.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,85 +1,88 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'contexts/TranslationContext';
|
||||
import {
|
||||
Radio as RadioUI,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@mui/material';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Radio.scss';
|
||||
|
||||
const Radio = memo((props) => {
|
||||
const { changeLanguage } = useTranslation();
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name));
|
||||
|
||||
const handleChange = useCallback(async (e) => {
|
||||
const newValue = e.target.value;
|
||||
const handleChange = useCallback(
|
||||
async (newValue) => {
|
||||
if (newValue === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue === 'loading') {
|
||||
return;
|
||||
}
|
||||
if (props.name === 'language') {
|
||||
changeLanguage(newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (props.name === 'language') {
|
||||
// Use context to change language directly - no EventBus needed
|
||||
changeLanguage(newValue);
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
return;
|
||||
}
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(newValue);
|
||||
}
|
||||
|
||||
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [value, props, changeLanguage]);
|
||||
EventBus.emit('refresh', props.category);
|
||||
},
|
||||
[value, props, changeLanguage],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel
|
||||
className={props.smallTitle ? 'radio-title-small' : 'radio-title'}
|
||||
component="legend"
|
||||
>
|
||||
{props.title}
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
aria-label={props.name}
|
||||
name={props.name}
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
>
|
||||
<div className="radio-group">
|
||||
{props.title && (
|
||||
<legend className={props.smallTitle ? 'radio-title-small' : 'radio-title'}>
|
||||
{props.title}
|
||||
</legend>
|
||||
)}
|
||||
<div className="radio-options" role="radiogroup" aria-label={props.name}>
|
||||
{props.options.map((option) => (
|
||||
<FormControlLabel
|
||||
value={option.value}
|
||||
control={<RadioUI />}
|
||||
label={option.name}
|
||||
<label
|
||||
key={option.value}
|
||||
/>
|
||||
className={`radio-option ${value === option.value ? 'selected' : ''} ${option.disabled || props.disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
<span className="radio-label">{option.name}</span>
|
||||
<input
|
||||
type="radio"
|
||||
name={props.name}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={() => handleChange(option.value)}
|
||||
disabled={option.disabled || props.disabled || false}
|
||||
className="radio-input"
|
||||
aria-label={option.name}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div className="radio-circle">
|
||||
{value === option.value && <div className="radio-dot" />}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
159
src/components/Form/Settings/Radio/Radio.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
@include keyframes(radioDotScale) {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
|
||||
.radio-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.17rem;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-title-small {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 16px 20px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: t($modal-secondaryColour);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.selected .radio-circle {
|
||||
@include themed {
|
||||
border-color: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-circle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: 20px;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
border: 2px solid t($modal-sidebarActive);
|
||||
background: t($modal-secondaryColour);
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.radio-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
@include animation(radioDotScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
|
||||
|
||||
@include themed {
|
||||
background: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.radio-input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
&:focus-visible + .radio-circle {
|
||||
@include themed {
|
||||
box-shadow: 0 0 0 3px t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/components/Form/Settings/SearchInput/SearchInput.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { memo } from 'react';
|
||||
import { MdSearch } from 'react-icons/md';
|
||||
|
||||
import './SearchInput.scss';
|
||||
|
||||
const SearchInput = memo(({ value, onChange, placeholder, fullWidth }) => {
|
||||
return (
|
||||
<div className={`search-input-container${fullWidth ? ' full-width' : ''}`}>
|
||||
<MdSearch className="search-input-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="search-input-field"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
|
||||
export { SearchInput as default, SearchInput };
|
||||
48
src/components/Form/Settings/SearchInput/SearchInput.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.search-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 250px;
|
||||
|
||||
&.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
font-size: 20px;
|
||||
pointer-events: none;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-field {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 16px 0 44px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: 24px;
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/SearchInput/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SearchInput';
|
||||
@@ -1,23 +1,20 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { memo, useState, useCallback, useRef } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Slider } from '@mui/material';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Slider.scss';
|
||||
|
||||
const SliderComponent = memo((props) => {
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name) || props.default);
|
||||
const animationRef = useRef(null);
|
||||
|
||||
const handleChange = useCallback((e, text) => {
|
||||
let newValue = e.target.value;
|
||||
newValue = Number(newValue);
|
||||
|
||||
if (text) {
|
||||
if (newValue === '') {
|
||||
setValue(0);
|
||||
return;
|
||||
}
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
let newValue = e.target.value;
|
||||
newValue = Number(newValue);
|
||||
|
||||
if (newValue > props.max) {
|
||||
newValue = props.max;
|
||||
@@ -26,52 +23,104 @@ const SliderComponent = memo((props) => {
|
||||
if (newValue < props.min) {
|
||||
newValue = props.min;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
localStorage.setItem(props.name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [props]);
|
||||
EventBus.emit('refresh', props.category);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
||||
const resetItem = useCallback(() => {
|
||||
handleChange({
|
||||
target: {
|
||||
value: props.default || '',
|
||||
},
|
||||
});
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
|
||||
const startValue = Number(value);
|
||||
const endValue = Number(props.default || 0);
|
||||
const duration = 300; // milliseconds
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const currentValue = startValue + (endValue - startValue) * easeOutCubic;
|
||||
const roundedValue = Math.round(currentValue / (Number(props.step) || 1)) * (Number(props.step) || 1);
|
||||
|
||||
localStorage.setItem(props.name, roundedValue);
|
||||
setValue(roundedValue);
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Ensure we end exactly at the target value
|
||||
localStorage.setItem(props.name, endValue);
|
||||
setValue(endValue);
|
||||
EventBus.emit('refresh', props.category);
|
||||
}
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [handleChange, props.default]);
|
||||
}, [value, props]);
|
||||
|
||||
const percentage =
|
||||
((Number(value) - Number(props.min)) / (Number(props.max) - Number(props.min))) * 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={'sliderTitle'}>
|
||||
{props.title}
|
||||
<span>{Number(value)}</span>
|
||||
<span className="link" onClick={resetItem}>
|
||||
<div className="slider-container">
|
||||
<div className="slider-header">
|
||||
<span className="slider-value">{Number(value)}</span>
|
||||
<span className="slider-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</span>
|
||||
<Slider
|
||||
value={Number(value)}
|
||||
onChange={handleChange}
|
||||
valueLabelDisplay="auto"
|
||||
default={Number(props.default)}
|
||||
min={Number(props.min)}
|
||||
max={Number(props.max)}
|
||||
step={Number(props.step) || 1}
|
||||
getAriaValueText={(value) => `${value}`}
|
||||
marks={props.marks || []}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
<div className="slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
className="slider-input"
|
||||
value={Number(value)}
|
||||
onChange={handleChange}
|
||||
min={Number(props.min)}
|
||||
max={Number(props.max)}
|
||||
step={Number(props.step) || 1}
|
||||
style={{ '--slider-percentage': `${percentage}%` }}
|
||||
aria-label={props.title}
|
||||
aria-valuemin={Number(props.min)}
|
||||
aria-valuemax={Number(props.max)}
|
||||
aria-valuenow={Number(value)}
|
||||
disabled={props.disabled || false}
|
||||
/>
|
||||
{props.marks && props.marks.length > 0 && (
|
||||
<div className="slider-marks">
|
||||
{props.marks.map((mark) => (
|
||||
<span
|
||||
key={mark.value}
|
||||
className="slider-mark"
|
||||
style={{
|
||||
left: `${((mark.value - Number(props.min)) / (Number(props.max) - Number(props.min))) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{mark.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
174
src/components/Form/Settings/Slider/Slider.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.slider-container {
|
||||
width: 300px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
|
||||
@include themed {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
t($link) 0%,
|
||||
t($link) var(--slider-percentage),
|
||||
t($modal-sidebarActive) var(--slider-percentage),
|
||||
t($modal-sidebarActive) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@include themed {
|
||||
background: t($color);
|
||||
border: 2px solid t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
|
||||
@include themed {
|
||||
background: t($color);
|
||||
border: 2px solid t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
@include themed {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
|
||||
}
|
||||
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
@include themed {
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2), 0 0 0 3px t($link);
|
||||
}
|
||||
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
&::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
cursor: not-allowed;
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
cursor: not-allowed;
|
||||
transform: scale(1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slider-marks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
margin-top: 8px;
|
||||
|
||||
.slider-mark {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { Switch as SwitchUI, FormControlLabel } from '@mui/material';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Switch.scss';
|
||||
|
||||
const Switch = memo((props) => {
|
||||
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
|
||||
|
||||
@@ -32,18 +33,20 @@ const Switch = memo((props) => {
|
||||
}, [checked, props]);
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<SwitchUI
|
||||
name={props.name}
|
||||
color="primary"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
label={props.header ? '' : props.text}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
<div className="switch-wrapper">
|
||||
{!props.header && <span className="switch-label">{props.text}</span>}
|
||||
<div className={`switch-track ${checked ? 'checked' : ''}`} onClick={handleChange}>
|
||||
<div className="switch-thumb" />
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={props.name}
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
className="switch-input"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
63
src/components/Form/Settings/Switch/Switch.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
|
||||
.switch-label {
|
||||
flex: 1;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
@include themed {
|
||||
background: t($link);
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@include themed {
|
||||
background: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.switch-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,95 @@
|
||||
import variables from 'config/variables';
|
||||
import { memo, useState, useCallback } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TextField } from '@mui/material';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
import './Text.scss';
|
||||
|
||||
const Text = memo((props) => {
|
||||
const [value, setValue] = useState(localStorage.getItem(props.name) || '');
|
||||
const { name, upperCaseFirst, element, category, onChange, title, textarea, customcss, placeholder } = props;
|
||||
const defaultValue = props.default;
|
||||
const [value, setValue] = useState(localStorage.getItem(name) || '');
|
||||
|
||||
const handleChange = useCallback((e) => {
|
||||
let { value } = e.target;
|
||||
const handleChange = useCallback(
|
||||
(e) => {
|
||||
let newValue = e.target.value;
|
||||
|
||||
// Alex wanted font to work with montserrat and Montserrat, so I made it work
|
||||
if (props.upperCaseFirst === true) {
|
||||
value = value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
localStorage.setItem(props.name, value);
|
||||
setValue(value);
|
||||
|
||||
// Call parent onChange if provided
|
||||
if (props.onChange) {
|
||||
props.onChange(value);
|
||||
}
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
if (upperCaseFirst === true) {
|
||||
newValue = newValue.charAt(0).toUpperCase() + newValue.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [props.name, props.upperCaseFirst, props.element, props.category, props.onChange]);
|
||||
localStorage.setItem(name, newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
if (!document.querySelector(element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', category);
|
||||
},
|
||||
[name, upperCaseFirst, element, category, onChange],
|
||||
);
|
||||
|
||||
const resetItem = useCallback(() => {
|
||||
handleChange({
|
||||
target: {
|
||||
value: props.default || '',
|
||||
value: defaultValue || '',
|
||||
},
|
||||
});
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [handleChange, props.default]);
|
||||
}, [handleChange, defaultValue]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.textarea === true ? (
|
||||
<TextField
|
||||
label={props.title}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
varient="outlined"
|
||||
className={props.customcss ? 'customcss' : ''}
|
||||
multiline
|
||||
spellCheck={false}
|
||||
minRows={4}
|
||||
maxRows={10}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<div className="text-field-container">
|
||||
{textarea === true ? (
|
||||
<div className={`text-field ${customcss ? 'customcss' : ''}`}>
|
||||
{title && (
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">{title}</label>
|
||||
<span className="text-field-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
spellCheck={false}
|
||||
rows={4}
|
||||
className="text-field-textarea"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TextField
|
||||
label={props.title}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
varient="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
placeholder={props.placeholder || ''}
|
||||
/>
|
||||
<div className="text-field">
|
||||
{title && (
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">{title}</label>
|
||||
<span className="text-field-reset" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder || ''}
|
||||
className="text-field-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="link" onClick={resetItem}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
110
src/components/Form/Settings/Text/Text.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.text-field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.text-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.text-field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.text-field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-input {
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-textarea {
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
transition: 0.2s ease;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.customcss .text-field-textarea {
|
||||
font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console',
|
||||
'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono',
|
||||
'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace !important;
|
||||
}
|
||||
}
|
||||
57
src/components/Form/Settings/Textarea/Textarea.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { memo, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
import './Textarea.scss';
|
||||
|
||||
const Textarea = memo(({ value, onChange, placeholder, minRows = 1, maxRows, className, style, readOnly }) => {
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
// Reset height to auto to get the correct scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
// Calculate line height
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const lineHeight = parseInt(computedStyle.lineHeight) || 24;
|
||||
const paddingTop = parseInt(computedStyle.paddingTop) || 0;
|
||||
const paddingBottom = parseInt(computedStyle.paddingBottom) || 0;
|
||||
|
||||
// Calculate min and max heights
|
||||
const minHeight = (minRows * lineHeight) + paddingTop + paddingBottom;
|
||||
const maxHeight = maxRows ? (maxRows * lineHeight) + paddingTop + paddingBottom : Infinity;
|
||||
|
||||
// Set the height based on content, clamped between min and max
|
||||
const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}, [minRows, maxRows]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
// Adjust on mount and window resize
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={`textarea-autosize${className ? ` ${className}` : ''}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
style={style}
|
||||
readOnly={readOnly}
|
||||
rows={minRows}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea as default, Textarea };
|
||||
34
src/components/Form/Settings/Textarea/Textarea.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.textarea-autosize {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
transition: 0.2s ease;
|
||||
font-family: inherit;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
color: t($color);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: t($color);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
&[readonly] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/Textarea/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Textarea';
|
||||
@@ -3,6 +3,8 @@ export * from './ChipSelect';
|
||||
export * from './Dropdown';
|
||||
export * from './FileUpload';
|
||||
export * from './Radio';
|
||||
export * from './SearchInput';
|
||||
export * from './Slider';
|
||||
export * from './Switch';
|
||||
export * from './Text';
|
||||
export * from './Textarea';
|
||||
|
||||
@@ -25,4 +25,4 @@ export const EMAIL = 'hello@muetab.com';
|
||||
export const TWITTER_HANDLE = 'getmue';
|
||||
export const DISCORD_SERVER = 'zv8C9F8';
|
||||
|
||||
export const VERSION = '7.5.0';
|
||||
export const VERSION = '7.6.0';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
65
src/features/background/options/FolderTaggingModal.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import variables from 'config/variables';
|
||||
import { MdClose } from 'react-icons/md';
|
||||
import { Button } from 'components/Elements';
|
||||
|
||||
const FolderTaggingModal = ({ files, onConfirm, onCancel }) => {
|
||||
const [folderName, setFolderName] = useState('');
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(folderName.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="smallModal">
|
||||
<div className="shareHeader">
|
||||
<span className="title">
|
||||
{variables.getMessage('modals.main.settings.sections.background.source.tag_images')}
|
||||
</span>
|
||||
<button className="closeModal" onClick={onCancel}>
|
||||
<MdClose />
|
||||
</button>
|
||||
</div>
|
||||
<div className="taggingModalContent">
|
||||
<p className="subtitle">
|
||||
{variables.getMessage('modals.main.settings.sections.background.source.tag_description', {
|
||||
count: files.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="taggingInput">
|
||||
<label>
|
||||
{variables.getMessage('modals.main.settings.sections.background.source.folder_name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={variables.getMessage(
|
||||
'modals.main.settings.sections.background.source.folder_placeholder',
|
||||
)}
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resetFooter">
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={onCancel}
|
||||
label={variables.getMessage('modals.main.settings.buttons.cancel')}
|
||||
/>
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={handleConfirm}
|
||||
label={variables.getMessage('modals.main.settings.buttons.continue')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderTaggingModal;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,27 +84,24 @@ const Added = memo(() => {
|
||||
|
||||
const sortAddons = useCallback((value, sendEvent) => {
|
||||
const installedItems = JSON.parse(localStorage.getItem('installed'));
|
||||
|
||||
|
||||
switch (value) {
|
||||
case 'newest':
|
||||
installedItems.reverse();
|
||||
break;
|
||||
case 'oldest':
|
||||
break;
|
||||
case 'a-z':
|
||||
installedItems.sort((a, b) => {
|
||||
if (a.display_name < b.display_name) {
|
||||
return -1;
|
||||
}
|
||||
if (a.display_name > b.display_name) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
const nameA = (a.display_name || a.name || '').toLowerCase();
|
||||
const nameB = (b.display_name || b.name || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
break;
|
||||
case 'z-a':
|
||||
installedItems.sort();
|
||||
installedItems.reverse();
|
||||
case 'recently-updated':
|
||||
installedItems.sort((a, b) => {
|
||||
const dateA = a.updated_at ? new Date(a.updated_at) : new Date(0);
|
||||
const dateB = b.updated_at ? new Date(b.updated_at) : new Date(0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -154,6 +151,12 @@ const Added = memo(() => {
|
||||
setInstalled([]);
|
||||
}, [installed]);
|
||||
|
||||
const handleUninstall = useCallback((type, name) => {
|
||||
uninstall(type, name);
|
||||
toast(variables.getMessage('toasts.uninstalled'));
|
||||
setInstalled(JSON.parse(localStorage.getItem('installed')));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sortAddons(localStorage.getItem('sortAddons'), false);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -243,9 +246,8 @@ const Added = memo(() => {
|
||||
onChange={(value) => sortAddons(value)}
|
||||
items={[
|
||||
{ value: 'newest', text: variables.getMessage('modals.main.addons.sort.newest') },
|
||||
{ value: 'oldest', text: variables.getMessage('modals.main.addons.sort.oldest') },
|
||||
{ value: 'a-z', text: variables.getMessage('modals.main.addons.sort.a_z') },
|
||||
{ value: 'z-a', text: variables.getMessage('modals.main.addons.sort.z_a') },
|
||||
{ value: 'recently-updated', text: 'Recently Updated' },
|
||||
]}
|
||||
/>
|
||||
<Items
|
||||
@@ -254,6 +256,7 @@ const Added = memo(() => {
|
||||
filter=""
|
||||
toggleFunction={(input) => toggle('item', input)}
|
||||
showCreateYourOwn={false}
|
||||
onUninstall={handleUninstall}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,9 @@ import variables from 'config/variables';
|
||||
import { useState } from 'react';
|
||||
import { MdCancel, MdAdd, MdOutlineTextsms } from 'react-icons/md';
|
||||
import { toast } from 'react-toastify';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
|
||||
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
|
||||
import { Textarea } from 'components/Form/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
@@ -82,14 +82,13 @@ const MessageOptions = () => {
|
||||
<span className="subtitle">
|
||||
{variables.getMessage(`${MESSAGE_SECTION}.title`)}
|
||||
</span>
|
||||
<TextareaAutosize
|
||||
<Textarea
|
||||
value={messages[index]}
|
||||
placeholder={variables.getMessage(
|
||||
'modals.main.settings.sections.message.content',
|
||||
)}
|
||||
onChange={(e) => message(e, true, index)}
|
||||
varient="outlined"
|
||||
style={{ padding: '0' }}
|
||||
minRows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,45 @@ import Preview from '../../helpers/preview/Preview';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
import { parseDeepLink, shouldAutoOpenModal, updateHash } from 'utils/deepLinking';
|
||||
import { install } from 'utils/marketplace';
|
||||
|
||||
import Welcome from 'features/welcome/Welcome';
|
||||
|
||||
const DEFAULT_PACK_ID = '0c8a5bdebd13';
|
||||
|
||||
const isDefaultPackInstalled = () => {
|
||||
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
|
||||
return installed.some((item) => item.id === DEFAULT_PACK_ID);
|
||||
};
|
||||
|
||||
const isDefaultPackUninstalled = () => {
|
||||
const uninstalledPacks = JSON.parse(localStorage.getItem('uninstalledPacks') || '[]');
|
||||
return uninstalledPacks.includes(DEFAULT_PACK_ID);
|
||||
};
|
||||
|
||||
const tryInstallDefaultPack = async () => {
|
||||
// Don't install if offline mode, already installed, or explicitly uninstalled
|
||||
if (
|
||||
localStorage.getItem('offlineMode') === 'true' ||
|
||||
isDefaultPackInstalled() ||
|
||||
isDefaultPackUninstalled()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${variables.constants.API_URL}/marketplace/item/${DEFAULT_PACK_ID}`,
|
||||
);
|
||||
const { data } = await response.json();
|
||||
install(data.type, data, false, true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to install default pack:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const Modals = () => {
|
||||
const [mainModal, setMainModal] = useState(false);
|
||||
const [updateModal, setUpdateModal] = useState(false);
|
||||
@@ -60,6 +96,15 @@ const Modals = () => {
|
||||
localStorage.setItem('showReminder', false);
|
||||
}
|
||||
|
||||
// Try to install default pack if it wasn't installed during welcome (e.g., no internet)
|
||||
if (localStorage.getItem('showWelcome') !== 'true') {
|
||||
tryInstallDefaultPack().then((installed) => {
|
||||
if (installed) {
|
||||
EventBus.emit('refresh', 'quote');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for EventBus modal open requests
|
||||
const handleModalOpen = (data) => {
|
||||
if (data === 'openMainModal') {
|
||||
@@ -76,9 +121,12 @@ const Modals = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeWelcome = () => {
|
||||
const closeWelcome = async () => {
|
||||
localStorage.setItem('showWelcome', false);
|
||||
setWelcomeModal(false);
|
||||
|
||||
await tryInstallDefaultPack();
|
||||
|
||||
EventBus.emit('refresh', 'widgetsWelcomeDone');
|
||||
EventBus.emit('refresh', 'widgets');
|
||||
EventBus.emit('refresh', 'backgroundwelcome');
|
||||
|
||||
@@ -2,7 +2,6 @@ import variables from 'config/variables';
|
||||
import { useState, memo } from 'react';
|
||||
import { Checkbox, Slider } from 'components/Form/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
import { TextField } from '@mui/material';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
@@ -39,22 +38,26 @@ function ExperimentalOptions() {
|
||||
element=".other"
|
||||
/>
|
||||
<p style={{ textAlign: 'left', width: '100%' }}>Send Event</p>
|
||||
<TextField
|
||||
label={'Type'}
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
spellCheck={false}
|
||||
varient="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<TextField
|
||||
label={'Name'}
|
||||
value={eventName}
|
||||
onChange={(e) => setEventName(e.target.value)}
|
||||
spellCheck={false}
|
||||
varient="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<div className="text-field">
|
||||
<label className="text-field-label">Type</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={eventType || ''}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-field">
|
||||
<label className="text-field-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={eventName || ''}
|
||||
onChange={(e) => setEventName(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => EventBus.emit(eventType, eventName)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useT, useTranslation } from 'contexts/TranslationContext';
|
||||
|
||||
import { MdOutlineOpenInNew, MdSearch, MdComputer } from 'react-icons/md';
|
||||
import { TextField, InputAdornment } from '@mui/material';
|
||||
import { MdOutlineOpenInNew, MdComputer } from 'react-icons/md';
|
||||
|
||||
import { Radio, Checkbox } from 'components/Form/Settings';
|
||||
import { Radio, Checkbox, SearchInput } from 'components/Form/Settings';
|
||||
|
||||
import languages from '@/i18n/languages.json';
|
||||
import translationPercentages from '@/i18n/translationPercentages.json';
|
||||
@@ -123,35 +122,10 @@ const LanguageOptions = () => {
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
<SearchInput
|
||||
placeholder={t('modals.main.settings.sections.language.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<MdSearch style={{ color: '#888' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
width: '250px',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '24px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
'& fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{currentLangOption && (
|
||||
<div style={{ color: '#888', whiteSpace: 'nowrap' }}>
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useT } from 'contexts';
|
||||
|
||||
import { MdContentCopy, MdAssignment, MdPushPin, MdDownload } from 'react-icons/md';
|
||||
import { useFloating, shift } from '@floating-ui/react-dom';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { Textarea } from 'components/Form/Settings';
|
||||
|
||||
import { saveFile } from 'utils/saveFile';
|
||||
import EventBus from 'utils/eventbus';
|
||||
@@ -112,12 +112,11 @@ const Notes = ({ notesRef, floatRef, position, xPosition, yPosition }) => {
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TextareaAutosize
|
||||
<Textarea
|
||||
placeholder={t('widgets.navbar.notes.placeholder')}
|
||||
value={notes}
|
||||
onChange={handleSetNotes}
|
||||
minRows={5}
|
||||
maxLength={10000}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
MdPlaylistAdd,
|
||||
MdOutlineDragIndicator,
|
||||
MdPlaylistRemove,
|
||||
MdCheck,
|
||||
} from 'react-icons/md';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import { Textarea } from 'components/Form/Settings';
|
||||
import { shift, useFloating } from '@floating-ui/react-dom';
|
||||
import {
|
||||
DndContext,
|
||||
@@ -210,15 +209,18 @@ function Todo({ todoRef, floatRef, position, xPosition, yPosition }) {
|
||||
<SortableItem key={index} id={index}>
|
||||
{({ attributes, listeners }) => (
|
||||
<div className={'todoRow' + (todoItem.done ? ' done' : '')}>
|
||||
<Checkbox
|
||||
checked={todoItem.done}
|
||||
<div
|
||||
className={'todo-checkbox' + (todoItem.done ? ' checked' : '')}
|
||||
onClick={() => updateTodo('done', index)}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
>
|
||||
{todoItem.done && <MdCheck />}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder={t('widgets.navbar.notes.placeholder')}
|
||||
value={todoItem.value}
|
||||
onChange={(data) => updateTodo('set', index, data)}
|
||||
readOnly={todoItem.done}
|
||||
minRows={1}
|
||||
/>
|
||||
<Tooltip
|
||||
title={t(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,58 +1,60 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MdContentCopy, MdStarBorder, MdStar, MdIosShare } from 'react-icons/md';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { useT } from 'contexts';
|
||||
import variables from 'config/variables';
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
/**
|
||||
* Quote action buttons component
|
||||
*/
|
||||
export default function QuoteButtons({
|
||||
onCopy,
|
||||
onFavourite,
|
||||
onShare,
|
||||
isFavourited,
|
||||
}) {
|
||||
export default function QuoteButtons({ onCopy, onFavourite, onShare, isFavourited }) {
|
||||
const t = useT();
|
||||
const showCopy = localStorage.getItem('copyButton') !== 'false';
|
||||
const showShare = localStorage.getItem('quoteShareButton') !== 'false';
|
||||
const showFavourite = localStorage.getItem('favouriteQuoteEnabled') === 'true';
|
||||
const [showCopy, setShowCopy] = useState(localStorage.getItem('copyButton') !== 'false');
|
||||
const [showShare, setShowShare] = useState(localStorage.getItem('quoteShareButton') !== 'false');
|
||||
const [showFavourite, setShowFavourite] = useState(
|
||||
localStorage.getItem('favouriteQuoteEnabled') === 'true',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRefresh = (data) => {
|
||||
if (data === 'quote') {
|
||||
setShowCopy(localStorage.getItem('copyButton') !== 'false');
|
||||
setShowShare(localStorage.getItem('quoteShareButton') !== 'false');
|
||||
setShowFavourite(localStorage.getItem('favouriteQuoteEnabled') === 'true');
|
||||
}
|
||||
};
|
||||
|
||||
EventBus.on('refresh', handleRefresh);
|
||||
return () => {
|
||||
EventBus.off('refresh', handleRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCopy && (
|
||||
<Tooltip title={t('widgets.quote.copy')}>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
aria-label={t('widgets.quote.copy')}
|
||||
>
|
||||
<button onClick={onCopy} aria-label={t('widgets.quote.copy')}>
|
||||
<MdContentCopy className="copyButton" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showShare && (
|
||||
<Tooltip title={t('widgets.quote.share')}>
|
||||
<button
|
||||
onClick={onShare}
|
||||
aria-label={t('widgets.quote.share')}
|
||||
>
|
||||
<button onClick={onShare} aria-label={t('widgets.quote.share')}>
|
||||
<MdIosShare className="copyButton" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showFavourite && (
|
||||
<Tooltip
|
||||
title={
|
||||
isFavourited
|
||||
? t('widgets.quote.unfavourite')
|
||||
: t('widgets.quote.favourite')
|
||||
}
|
||||
title={isFavourited ? t('widgets.quote.unfavourite') : t('widgets.quote.favourite')}
|
||||
>
|
||||
<button
|
||||
onClick={onFavourite}
|
||||
aria-label={
|
||||
isFavourited
|
||||
? t('widgets.quote.unfavourite')
|
||||
: t('widgets.quote.favourite')
|
||||
isFavourited ? t('widgets.quote.unfavourite') : t('widgets.quote.favourite')
|
||||
}
|
||||
>
|
||||
{isFavourited ? (
|
||||
|
||||
@@ -83,7 +83,13 @@ export function useQuoteLoader(updateQuote) {
|
||||
|
||||
const getQuote = useCallback(async () => {
|
||||
const offline = localStorage.getItem('offlineMode') === 'true';
|
||||
const type = localStorage.getItem('quoteType') || 'api';
|
||||
let type = localStorage.getItem('quoteType') || 'quote_pack';
|
||||
|
||||
// Migrate deprecated 'api' type to 'quote_pack'
|
||||
if (type === 'api') {
|
||||
type = 'quote_pack';
|
||||
localStorage.setItem('quoteType', 'quote_pack');
|
||||
}
|
||||
|
||||
// Check for favourite quote first
|
||||
const favouriteQuote = localStorage.getItem('favouriteQuote');
|
||||
@@ -128,7 +134,8 @@ export function useQuoteLoader(updateQuote) {
|
||||
});
|
||||
}
|
||||
|
||||
case 'quote_pack': {
|
||||
case 'quote_pack':
|
||||
default: {
|
||||
if (offline) return doOffline();
|
||||
|
||||
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
|
||||
@@ -138,56 +145,31 @@ export function useQuoteLoader(updateQuote) {
|
||||
...quote,
|
||||
fallbackauthorimg: item.icon_url,
|
||||
packName: item.display_name || item.name,
|
||||
noAuthorImg: item.noAuthorImg || quote.noAuthorImg,
|
||||
})));
|
||||
|
||||
if (quotePack.length === 0) return doOffline();
|
||||
|
||||
const data = quotePack[Math.floor(Math.random() * quotePack.length)];
|
||||
const hasAuthor = data.author && data.author.trim() !== '';
|
||||
const displayAuthor = hasAuthor ? data.author : data.packName;
|
||||
|
||||
// Try to get author image from Wikipedia unless pack disables it
|
||||
let authorimgdata = { authorimg: data.fallbackauthorimg, authorimglicense: null };
|
||||
if (hasAuthor && !data.noAuthorImg) {
|
||||
const wikiImg = await getAuthorImg(data.author);
|
||||
if (wikiImg.authorimg) {
|
||||
authorimgdata = wikiImg;
|
||||
}
|
||||
}
|
||||
|
||||
return updateQuote({
|
||||
quote: `"${data.quote}"`,
|
||||
author: hasAuthor ? data.author : data.packName,
|
||||
author: displayAuthor,
|
||||
authorlink: hasAuthor ? getAuthorLink(data.author) : null,
|
||||
authorimg: data.fallbackauthorimg,
|
||||
...authorimgdata,
|
||||
});
|
||||
}
|
||||
|
||||
case 'api': {
|
||||
if (offline) return doOffline();
|
||||
|
||||
const fetchAPIQuote = async () => {
|
||||
const response = await fetch(
|
||||
`${variables.constants.API_URL}/quotes/random`
|
||||
).then(res => res.json());
|
||||
|
||||
if (response.statusCode === 429) return null;
|
||||
|
||||
const authorimgdata = await getAuthorImg(response.author);
|
||||
return {
|
||||
quote: `"${response.quote.replace(/\s+$/g, '')}"`,
|
||||
author: response.author,
|
||||
authorlink: getAuthorLink(response.author),
|
||||
...authorimgdata,
|
||||
authorOccupation: response.author_occupation,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem('nextQuote')) || await fetchAPIQuote();
|
||||
localStorage.setItem('nextQuote', null);
|
||||
|
||||
if (data) {
|
||||
updateQuote(data);
|
||||
localStorage.setItem('currentQuote', JSON.stringify(data));
|
||||
localStorage.setItem('nextQuote', JSON.stringify(await fetchAPIQuote()));
|
||||
} else {
|
||||
doOffline();
|
||||
}
|
||||
} catch {
|
||||
doOffline();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [updateQuote, getAuthorLink, getAuthorImg, doOffline]);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import variables from 'config/variables';
|
||||
import React, { useState } from 'react';
|
||||
import { MdCancel, MdAdd, MdSource, MdOutlineFormatQuote } from 'react-icons/md';
|
||||
import TextareaAutosize from '@mui/material/TextareaAutosize';
|
||||
|
||||
import {
|
||||
Header,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
Section,
|
||||
PreferencesWrapper,
|
||||
} from 'components/Layout/Settings';
|
||||
import { Checkbox, Dropdown } from 'components/Form/Settings';
|
||||
import { Checkbox, Dropdown, Textarea } from 'components/Form/Settings';
|
||||
import { Button } from 'components/Elements';
|
||||
|
||||
const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) => {
|
||||
@@ -23,7 +22,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>
|
||||
|
||||
@@ -8,6 +8,13 @@ import EventBus from 'utils/eventbus';
|
||||
|
||||
import './clock.scss';
|
||||
|
||||
// Helper function to format padded time values while preserving padding
|
||||
const formatPaddedDigits = (value) => {
|
||||
const str = String(value);
|
||||
// Format each digit individually to preserve padding with locale numerals
|
||||
return str.split('').map(digit => formatDigits(digit)).join('');
|
||||
};
|
||||
|
||||
const Clock = () => {
|
||||
const [timeType] = useState(localStorage.getItem('timeType'));
|
||||
const [time, setTime] = useState('');
|
||||
@@ -51,23 +58,22 @@ const Clock = () => {
|
||||
|
||||
if (localStorage.getItem('seconds') === 'true') {
|
||||
const secs = ('00' + now.getSeconds()).slice(-2);
|
||||
sec = `:${formatDigits(secs)}`;
|
||||
setFinalSeconds(formatDigits(secs));
|
||||
sec = `:${formatPaddedDigits(secs)}`;
|
||||
setFinalSeconds(formatPaddedDigits(secs));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('timeformat') === 'twentyfourhour') {
|
||||
if (zero === 'false') {
|
||||
const hours = now.getHours();
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
} else {
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
if (zero === 'true') {
|
||||
const hours = ('00' + now.getHours()).slice(-2);
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
|
||||
time = `${formatPaddedDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatPaddedDigits(hours));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
} else {
|
||||
const hours = now.getHours();
|
||||
time = `${formatDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
}
|
||||
|
||||
setTime(time);
|
||||
@@ -82,17 +88,16 @@ const Clock = () => {
|
||||
hours = 12;
|
||||
}
|
||||
|
||||
if (zero === 'false') {
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
} else {
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
if (zero === 'true') {
|
||||
const paddedHours = ('00' + hours).slice(-2);
|
||||
const minutes = ('00' + now.getMinutes()).slice(-2);
|
||||
time = `${formatDigits(paddedHours)}:${formatDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(paddedHours));
|
||||
setFinalMinute(formatDigits(minutes));
|
||||
time = `${formatPaddedDigits(paddedHours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatPaddedDigits(paddedHours));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
} else {
|
||||
time = `${formatDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
|
||||
setFinalHour(formatDigits(hours));
|
||||
setFinalMinute(formatPaddedDigits(minutes));
|
||||
}
|
||||
|
||||
setTime(time);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MdAutoAwesome } from 'react-icons/md';
|
||||
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
|
||||
import { useLocalStorageState } from 'utils/useLocalStorageState';
|
||||
import { Radio, Dropdown, Checkbox } from 'components/Form/Settings';
|
||||
import { TextField } from '@mui/material';
|
||||
import variables from 'config/variables';
|
||||
|
||||
const useWeatherSettings = () => {
|
||||
@@ -82,18 +81,26 @@ const WeatherOptions = () => {
|
||||
<Row>
|
||||
<Content title={variables.getMessage(`${WEATHER_SECTION}.location`)} />
|
||||
<Action>
|
||||
<TextField
|
||||
label={variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
value={location}
|
||||
onChange={changeLocation}
|
||||
placeholder="London"
|
||||
variant="outlined"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
<span className="link" onClick={getAutoLocation}>
|
||||
<MdAutoAwesome />
|
||||
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
|
||||
</span>
|
||||
<div className="text-field-container">
|
||||
<div className="text-field">
|
||||
<div className="text-field-header">
|
||||
<label className="text-field-label">
|
||||
{variables.getMessage(`${WEATHER_SECTION}.location`)}
|
||||
</label>
|
||||
<span className="text-field-reset" onClick={getAutoLocation}>
|
||||
<MdAutoAwesome />
|
||||
{variables.getMessage(`${WEATHER_SECTION}.auto`)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="text-field-input"
|
||||
value={location}
|
||||
onChange={changeLocation}
|
||||
placeholder="London"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Action>
|
||||
</Row>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { MdOutlineOpenInNew, MdSearch } from 'react-icons/md';
|
||||
import { TextField, InputAdornment } from '@mui/material';
|
||||
import { MdOutlineOpenInNew } from 'react-icons/md';
|
||||
import languages from '@/i18n/languages.json';
|
||||
import translationPercentages from '@/i18n/translationPercentages.json';
|
||||
import { useT, useTranslation } from 'contexts/TranslationContext';
|
||||
import variables from 'config/variables';
|
||||
|
||||
import { Radio } from 'components/Form/Settings';
|
||||
import { Radio, SearchInput } from 'components/Form/Settings';
|
||||
import { Header, Content } from '../Layout';
|
||||
|
||||
function ChooseLanguage() {
|
||||
@@ -107,37 +106,14 @@ function ChooseLanguage() {
|
||||
{t('modals.main.settings.sections.language.use_system')} ({systemLanguage.name})
|
||||
</button>
|
||||
)}
|
||||
<TextField
|
||||
placeholder={t('modals.main.settings.sections.language.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<MdSearch style={{ color: '#888' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
marginBottom: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: '24px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
'& fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<SearchInput
|
||||
placeholder={t('modals.main.settings.sections.language.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
<div className="languageSettings">
|
||||
<Radio name="language" options={filteredLanguages} category="welcomeLanguage" />
|
||||
</div>
|
||||
|
||||
@@ -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": "إنشاء",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||