mirror of
https://github.com/mue/mue.git
synced 2026-06-14 04:27:39 +02:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64538d0170 | ||
|
|
e123e06bdd | ||
|
|
facc95e8dd | ||
|
|
29fd7181c7 | ||
|
|
55a4b31e30 | ||
|
|
a3ca7ad9e9 | ||
|
|
6cb142b1b3 | ||
|
|
69d91cc6f1 | ||
|
|
1ef65fb5d2 | ||
|
|
b98478ef68 | ||
|
|
031d980301 | ||
|
|
04e5e26141 | ||
|
|
6c6542c08b | ||
|
|
8c4ea4cf6a | ||
|
|
e938eef740 | ||
|
|
8a28a27282 | ||
|
|
96e51c96cf | ||
|
|
8758188521 | ||
|
|
ad7963f8f5 | ||
|
|
bce58afa66 | ||
|
|
ce085b4457 | ||
|
|
2a35b55cff | ||
|
|
864579de84 | ||
|
|
9e75be2b89 | ||
|
|
59ceb40a32 | ||
|
|
348485a724 | ||
|
|
4ad799da3b | ||
|
|
f891a16350 | ||
|
|
2d7e1ad97a | ||
|
|
4d56bbb2c1 | ||
|
|
3af0b63a17 | ||
|
|
1b664ba23e | ||
|
|
eb3af777f5 | ||
|
|
bbca4b9c7b | ||
|
|
553d5001f6 | ||
|
|
cf3237f337 | ||
|
|
9e336b0582 | ||
|
|
474b4ae237 | ||
|
|
5cbcee522b | ||
|
|
d31bde91cf | ||
|
|
6231a4aa2d | ||
|
|
c23bd1a0e8 | ||
|
|
74565fbeb9 | ||
|
|
62f7e20f8e | ||
|
|
0af00072f1 | ||
|
|
dbd3eb11ae | ||
|
|
3c53d0322e | ||
|
|
dbfe7e52b5 | ||
|
|
5532d02603 | ||
|
|
1c2a43ae7f | ||
|
|
9731bc939e | ||
|
|
0d25fbca6d | ||
|
|
a0517acd53 | ||
|
|
8dd6cf2655 | ||
|
|
7a390b91ca | ||
|
|
192f951d41 | ||
|
|
94edeba10f | ||
|
|
d67cf65a0e | ||
|
|
1c7fe206af | ||
|
|
6044b02749 | ||
|
|
26a1da89b5 | ||
|
|
73ba9b5590 | ||
|
|
4aeda76cdf | ||
|
|
3a29f8b48f | ||
|
|
bb2b457c1c | ||
|
|
0ec0676308 | ||
|
|
ce2fec8407 | ||
|
|
7ed5984a3d | ||
|
|
cfb9915a8b | ||
|
|
d68b5c3d50 | ||
|
|
f0ff173b3b | ||
|
|
5246455aca | ||
|
|
ede1615ab8 | ||
|
|
fb9787642b | ||
|
|
ee89ebcd4d | ||
|
|
7dec0a844e | ||
|
|
1a8e91b02b | ||
|
|
06e00b3024 | ||
|
|
a75a8c2122 | ||
|
|
bafa2ecbe7 | ||
|
|
0acac6dcee | ||
|
|
e7ad7ba131 | ||
|
|
a9ab5d9651 | ||
|
|
09f2e0519d | ||
|
|
4c193cf7db | ||
|
|
be298266bf | ||
|
|
bbaadece43 | ||
|
|
d5d2efbd13 | ||
|
|
7641762557 | ||
|
|
987f6756a0 | ||
|
|
9040766fe3 | ||
|
|
bbaf4de7f4 | ||
|
|
3fc5d736c8 | ||
|
|
333a070020 | ||
|
|
cfb27ba392 | ||
|
|
99b139ffd6 | ||
|
|
646f19a78c | ||
|
|
9f461edb55 | ||
|
|
8356ea1af5 | ||
|
|
a361e6f3d5 | ||
|
|
baea8c4d6c | ||
|
|
f42bdf2fb8 | ||
|
|
fc3092ad44 | ||
|
|
99bd0a9d35 | ||
|
|
d937f8a82e | ||
|
|
cbedaf627c | ||
|
|
7c2213a38f | ||
|
|
e25c8dedb7 | ||
|
|
b54c573c8e | ||
|
|
bba18acd19 | ||
|
|
f534d531dc | ||
|
|
a017632597 | ||
|
|
8843178885 | ||
|
|
5ce553674c | ||
|
|
f7a799fe34 | ||
|
|
7415b9cd5c | ||
|
|
41e438ead4 | ||
|
|
7251c16250 | ||
|
|
21301d8a2a | ||
|
|
b742c6a768 | ||
|
|
f29b879215 | ||
|
|
09308a4452 | ||
|
|
e03b1d68d0 | ||
|
|
3e434341da | ||
|
|
49630ee375 | ||
|
|
9d98e2124a | ||
|
|
0c9a90d693 | ||
|
|
6c73bdb156 | ||
|
|
6465b88f30 | ||
|
|
a4e575c5f6 | ||
|
|
05bf8edeea | ||
|
|
50aae0f8ec | ||
|
|
6e852eb252 | ||
|
|
6cb8843b49 | ||
|
|
63b8742218 | ||
|
|
bebd551193 | ||
|
|
c71c6442db | ||
|
|
9e692b97ce | ||
|
|
7f0b37c713 | ||
|
|
4d8be5774f | ||
|
|
b7097979de | ||
|
|
7e6bc58f2c | ||
|
|
9fa1ddcab5 | ||
|
|
3dad52196d | ||
|
|
df23753971 | ||
|
|
401e711bd8 | ||
|
|
f493eb186e | ||
|
|
312ef49f78 | ||
|
|
79c8e1508f | ||
|
|
b5a451c70d | ||
|
|
7a589de14b | ||
|
|
9bf160094e | ||
|
|
f8746a31b0 | ||
|
|
864097c508 | ||
|
|
139c8e2914 | ||
|
|
f2a0330655 | ||
|
|
89523df1cf | ||
|
|
9462fe1b32 | ||
|
|
c7a2760709 | ||
|
|
f1e961e8e4 | ||
|
|
616055106b | ||
|
|
9677434c00 | ||
|
|
cac58cdaeb | ||
|
|
e42a218116 | ||
|
|
40c248985d | ||
|
|
d88ed2eedd | ||
|
|
ab2b969772 | ||
|
|
67ba0f6718 | ||
|
|
4cf5269cdc | ||
|
|
ce6b05f1a1 | ||
|
|
5c8d9a3a44 | ||
|
|
4a2f1334f3 | ||
|
|
155dc46e68 | ||
|
|
47b7397bd4 | ||
|
|
777f1faeb6 | ||
|
|
dfb0872633 | ||
|
|
c186c54749 | ||
|
|
5392e4b27d | ||
|
|
c13d6ce4ac | ||
|
|
9410d89cea | ||
|
|
a6e1490edb | ||
|
|
874866bf73 | ||
|
|
ecfb3c6648 | ||
|
|
01fcdbf9c7 | ||
|
|
2fca4bf9ac | ||
|
|
2918033afa | ||
|
|
a29984d3aa | ||
|
|
befed06832 |
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"typescript-lsp@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
5
.claude/settings.local.json
Normal file
5
.claude/settings.local.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(perl -i -pe:*)", "Bash(bun run:*)"]
|
||||
}
|
||||
}
|
||||
153
.github/.copilot-instructions.md
vendored
Normal file
153
.github/.copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
# Mue Project Guidelines for GitHub Copilot
|
||||
|
||||
## Project Overview
|
||||
|
||||
Mue is a fast, open-source browser extension that provides a customizable new tab page for Chrome, Firefox, Edge, and Safari. Built with React and Vite, it emphasizes privacy, performance, and extensibility.
|
||||
|
||||
## Technical Stack
|
||||
|
||||
- **Framework**: React 19 with hooks and functional components
|
||||
- **Build Tool**: Vite with SWC
|
||||
- **Package Manager**: Bun >= 1.3.0 (ALWAYS use Bun, never npm or yarn)
|
||||
- **Styling**: SCSS with component-scoped styles
|
||||
- **Target**: Browser extensions (Manifest V3)
|
||||
- **Testing**: Manual testing across Chrome, Firefox, and Safari
|
||||
|
||||
## Code Style & Quality
|
||||
|
||||
- Follow ESLint configuration in `eslint.config.js`
|
||||
- Run `bun run lint:fix` before committing
|
||||
- Run `bun run pretty` to format code with Prettier
|
||||
- Use conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, etc.
|
||||
- All commits must pass commitlint validation
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components (Elements, Form, Layout)
|
||||
├── features/ # Feature-specific code (background, weather, etc.)
|
||||
├── utils/ # Utility functions and helpers
|
||||
├── contexts/ # React Context providers
|
||||
├── hooks/ # Custom React hooks
|
||||
├── i18n/ # Internationalization files
|
||||
└── scss/ # Global styles, variables, mixins
|
||||
```
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### 1. Translation Files (IMPORTANT!)
|
||||
|
||||
- **`en_GB.json` is the base translation file**
|
||||
- Never edit other locale files directly
|
||||
- Workflow: Edit `en_GB.json` → Run `bun run translations`
|
||||
- This ensures consistent formatting across all 30+ locales
|
||||
- Translation files are in `src/i18n/locales/`
|
||||
|
||||
### 2. Branch Strategy
|
||||
|
||||
```
|
||||
dev (active development)
|
||||
↓
|
||||
beta (release candidates)
|
||||
↓
|
||||
main (production/stable)
|
||||
```
|
||||
|
||||
- All PRs target the `dev` branch
|
||||
- Never commit directly to `beta` or `main`
|
||||
- See `CONTRIBUTING.md` for full workflow
|
||||
|
||||
### 3. Package Manager
|
||||
|
||||
- **ALWAYS use Bun**, never npm or yarn
|
||||
- Commands: `bun install`, `bun run <script>`
|
||||
- Project requires Bun >= 1.3.0
|
||||
|
||||
### 4. Multi-Browser Support
|
||||
|
||||
- Test changes in Chrome, Firefox, and Safari when possible
|
||||
- Browser-specific builds are in `build/chrome/`, `build/firefox/`, etc.
|
||||
- Manifest files: `manifest/chrome.json`, `manifest/firefox.json`
|
||||
|
||||
### 5. State Management
|
||||
|
||||
- Use `useLocalStorageState` hook for persistent settings
|
||||
- Use React Context for shared state (see `src/contexts/`)
|
||||
- Store user data in localStorage or IndexedDB (via `customBackgroundDB.js`)
|
||||
|
||||
### 6. Styling
|
||||
|
||||
- SCSS files in `src/scss/`
|
||||
- Use existing variables from `_variables.scss`
|
||||
- Use mixins from `_mixins.scss` (especially for responsive design)
|
||||
- Mobile styles in `_mobile.scss`
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server
|
||||
bun run dev:host # Dev server accessible on network
|
||||
bun run build # Build for production (all browsers)
|
||||
bun run lint # Run linters
|
||||
bun run lint:fix # Auto-fix linting issues
|
||||
bun run pretty # Format code
|
||||
bun run translations # Update translation files
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Component Structure
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
import './ComponentName.scss';
|
||||
|
||||
export default function ComponentName({ prop1, prop2 }) {
|
||||
const [state, setState] = useState();
|
||||
|
||||
return <div className="component-name">{/* JSX content */}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Using localStorage
|
||||
|
||||
```jsx
|
||||
import { useLocalStorageState } from 'utils/useLocalStorageState';
|
||||
|
||||
const [value, setValue] = useLocalStorageState('settingKey', defaultValue);
|
||||
```
|
||||
|
||||
### Using Translations
|
||||
|
||||
```jsx
|
||||
import { useContext } from 'react';
|
||||
import { TranslationContext } from 'contexts';
|
||||
|
||||
const { t } = useContext(TranslationContext);
|
||||
const text = t('translation.key');
|
||||
```
|
||||
|
||||
## Things to Avoid
|
||||
|
||||
- ❌ Don't use npm or yarn (use Bun)
|
||||
- ❌ Don't edit locale files other than `en_GB.json`
|
||||
- ❌ Don't commit directly to `beta` or `main` branches
|
||||
- ❌ Don't introduce new dependencies without discussion
|
||||
- ❌ Don't skip linting/formatting before commits
|
||||
- ❌ Don't use class components (use functional components only)
|
||||
|
||||
## When Making Changes
|
||||
|
||||
1. Check which files need updates (component, styles, utils)
|
||||
2. Follow existing patterns in similar components
|
||||
3. Update translations in `en_GB.json` if adding new text
|
||||
4. Run `bun run lint:fix` and `bun run pretty`
|
||||
5. Test in at least Chrome and Firefox
|
||||
6. Write conventional commit message
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- See `CONTRIBUTING.md` for full contribution guidelines
|
||||
- See `docs/RELEASE_PROCESS.md` for release procedures
|
||||
- Check existing components in `src/components/` for patterns
|
||||
13
.github/dependabot.yml
vendored
13
.github/dependabot.yml
vendored
@@ -1 +1,12 @@
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "dev"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
|
||||
3
.github/workflows/automerge.yml
vendored
3
.github/workflows/automerge.yml
vendored
@@ -8,12 +8,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
bun-version: '1.3.1'
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
continue-on-error: true
|
||||
- name: Build
|
||||
run: bun run build
|
||||
automerge:
|
||||
|
||||
48
.github/workflows/beta-release.yml
vendored
48
.github/workflows/beta-release.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- beta
|
||||
tags:
|
||||
- "v*-beta.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -21,7 +23,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.1'
|
||||
bun-version: "1.3.1"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -37,34 +39,20 @@ jobs:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
# Check if this is actually a beta version
|
||||
if [[ ! "$VERSION" =~ -beta\. ]]; then
|
||||
echo "❌ Version $VERSION is not a beta version (must contain '-beta.')"
|
||||
echo "Skipping beta release. Use Version Bump workflow to create a beta version first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get the latest beta or production tag
|
||||
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous tag found, using all commits"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" HEAD)
|
||||
else
|
||||
echo "Generating changelog from $PREVIOUS_TAG to HEAD"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..HEAD)
|
||||
fi
|
||||
|
||||
# Only show commits on beta that are not yet in main (i.e. what's new for this beta)
|
||||
git fetch origin main --depth=100
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" origin/main..HEAD)
|
||||
|
||||
# Create changelog with categorization
|
||||
FEATURES=$(echo "$COMMITS" | grep -i "^- feat" || echo "")
|
||||
FIXES=$(echo "$COMMITS" | grep -i "^- fix" || echo "")
|
||||
CHORES=$(echo "$COMMITS" | grep -i "^- chore\|^- docs\|^- style\|^- refactor" || echo "")
|
||||
OTHER=$(echo "$COMMITS" | grep -v -i "^- feat\|^- fix\|^- chore\|^- docs\|^- style\|^- refactor" || echo "")
|
||||
|
||||
|
||||
{
|
||||
echo "changelog<<EOF"
|
||||
if [ -n "$FEATURES" ]; then
|
||||
@@ -103,35 +91,35 @@ jobs:
|
||||
- name: Create or Update GitHub Pre-Release
|
||||
run: |
|
||||
RELEASE_NOTES=$(cat <<EOF
|
||||
## 🧪 Mue Beta v${{ steps.version.outputs.version }}
|
||||
|
||||
## 🧪 Mue v${{ steps.version.outputs.version }}
|
||||
|
||||
**⚠️ This is a beta release for testing purposes only.**
|
||||
|
||||
|
||||
### Testing Instructions
|
||||
1. Download the appropriate ZIP file below
|
||||
2. For Chrome: Load as unpacked extension or install from [unlisted link](https://chromewebstore.google.com/detail/mue/bngmbednanpcfochchhgbkookpiaiaid) (dev team only)
|
||||
3. For Firefox: Install via about:debugging → Load Temporary Add-on
|
||||
4. Report issues at https://github.com/mue/mue/issues
|
||||
|
||||
|
||||
${{ steps.changelog.outputs.changelog }}
|
||||
|
||||
|
||||
### Installation Files
|
||||
- **Chrome/Edge**: \`chrome-${{ steps.version.outputs.version }}.zip\`
|
||||
- **Firefox**: \`firefox-${{ steps.version.outputs.version }}.zip\`
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
**🔗 Demo**: [demo.muetab.com](https://demo.muetab.com)
|
||||
**📱 Beta Branch Demo**: [mue-git-beta-mue.vercel.app](https://mue-git-beta-mue.vercel.app)
|
||||
EOF
|
||||
)
|
||||
|
||||
|
||||
if [ "${{ steps.check_release.outputs.exists }}" = "true" ]; then
|
||||
echo "Updating existing release..."
|
||||
gh release edit "v${{ steps.version.outputs.version }}" \
|
||||
--notes "$RELEASE_NOTES" \
|
||||
--prerelease
|
||||
|
||||
|
||||
# Upload new files (will replace if they exist)
|
||||
gh release upload "v${{ steps.version.outputs.version }}" \
|
||||
"build/chrome-${{ steps.version.outputs.version }}.zip" \
|
||||
@@ -142,7 +130,7 @@ jobs:
|
||||
gh release create "v${{ steps.version.outputs.version }}" \
|
||||
"build/chrome-${{ steps.version.outputs.version }}.zip" \
|
||||
"build/firefox-${{ steps.version.outputs.version }}.zip" \
|
||||
--title "Beta v${{ steps.version.outputs.version }}" \
|
||||
--title "v${{ steps.version.outputs.version }}" \
|
||||
--notes "$RELEASE_NOTES" \
|
||||
--prerelease
|
||||
fi
|
||||
|
||||
32
.github/workflows/hotfix-release.yml
vendored
32
.github/workflows/hotfix-release.yml
vendored
@@ -42,6 +42,21 @@ jobs:
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
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: Calculate hotfix version (auto-patch bump)
|
||||
id: version
|
||||
run: |
|
||||
@@ -101,23 +116,6 @@ jobs:
|
||||
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
|
||||
|
||||
23
.github/workflows/production-release.yml
vendored
23
.github/workflows/production-release.yml
vendored
@@ -162,6 +162,25 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Back-merge main to beta
|
||||
run: |
|
||||
git fetch origin beta
|
||||
git checkout beta
|
||||
git merge --no-ff main -m "chore: back-merge production release v${{ steps.version.outputs.version }} from main"
|
||||
git push origin beta
|
||||
|
||||
- name: Back-merge main to dev
|
||||
run: |
|
||||
git fetch origin dev
|
||||
git checkout dev
|
||||
git merge --no-ff main -m "chore: back-merge production release v${{ steps.version.outputs.version }} from main"
|
||||
git push origin dev
|
||||
|
||||
- name: Output success summary
|
||||
run: |
|
||||
echo "## 🚀 Production Release Published!" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -176,7 +195,7 @@ jobs:
|
||||
echo "### ⚠️ Manual Steps Required" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Go to [GitHub Actions](https://github.com/${{ github.repository }}/actions/workflows/submit.yml)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Click 'Run workflow'" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Enter tag: \`v${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Enter tag: \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "4. Click 'Run workflow' to submit to Chrome/Firefox/Edge stores" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📢 Post-Release Checklist" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -184,4 +203,4 @@ jobs:
|
||||
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
|
||||
echo "- [x] Back-merged \`main\` to \`beta\` and \`dev\` (automated)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
57
.github/workflows/submit-beta.yml
vendored
Normal file
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
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
|
||||
|
||||
153
.github/workflows/version-bump.yml
vendored
153
.github/workflows/version-bump.yml
vendored
@@ -1,31 +1,18 @@
|
||||
name: Version Bump
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_type:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch # 7.5.0 -> 7.5.1 (bug fixes)
|
||||
- minor # 7.5.0 -> 7.6.0 (new features)
|
||||
- major # 7.5.0 -> 8.0.0 (breaking changes)
|
||||
pre_release:
|
||||
description: 'Pre-release label (leave empty for stable release)'
|
||||
required: false
|
||||
type: choice
|
||||
options:
|
||||
- ''
|
||||
- beta
|
||||
- rc
|
||||
- alpha
|
||||
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
|
||||
@@ -44,105 +31,99 @@ jobs:
|
||||
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")
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
# Remove any pre-release suffix for base version
|
||||
BASE_VERSION=$(echo $CURRENT_VERSION | sed 's/-.*$//')
|
||||
IFS='.' read -r -a VERSION_PARTS <<< "$BASE_VERSION"
|
||||
|
||||
# Detect if current version is already a pre-release
|
||||
IS_PRERELEASE=false
|
||||
case "$CURRENT_VERSION" in
|
||||
*-*) IS_PRERELEASE=true ;;
|
||||
esac
|
||||
|
||||
|
||||
MAJOR="${VERSION_PARTS[0]}"
|
||||
MINOR="${VERSION_PARTS[1]}"
|
||||
PATCH="${VERSION_PARTS[2]}"
|
||||
|
||||
# If requesting a pre-release and current is already a pre-release, keep base version
|
||||
# Otherwise, bump the version based on type
|
||||
if [ -n "${{ github.event.inputs.pre_release }}" ] && [ "$IS_PRERELEASE" = "true" ]; then
|
||||
# Keep existing base version for iterative betas (7.6.0-beta.1 -> 7.6.0-beta.2)
|
||||
NEW_VERSION="$BASE_VERSION"
|
||||
else
|
||||
# Bump version based on type
|
||||
case "${{ github.event.inputs.bump_type }}" in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
fi
|
||||
|
||||
# Add pre-release label if specified
|
||||
if [ -n "${{ github.event.inputs.pre_release }}" ]; then
|
||||
# Get beta number by counting existing beta tags for this version
|
||||
BETA_COUNT=$(git tag -l "v${NEW_VERSION}-${{ github.event.inputs.pre_release }}.*" | wc -l)
|
||||
|
||||
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}-${{ github.event.inputs.pre_release }}.${BETA_NUM}"
|
||||
NEW_VERSION="${NEW_VERSION}-beta.${BETA_NUM}"
|
||||
fi
|
||||
|
||||
|
||||
# Browser extension manifests require clean semver — strip pre-release suffix
|
||||
STABLE_VERSION=$(echo "$NEW_VERSION" | sed 's/-.*$//')
|
||||
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "New version will be: $NEW_VERSION"
|
||||
echo "stable_version=$STABLE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update package.json
|
||||
run: |
|
||||
bun x json -I -f package.json -e "this.version='${{ steps.version.outputs.new_version }}'"
|
||||
run: bun x json -I -f package.json -e "this.version='${{ steps.version.outputs.new_version }}'"
|
||||
|
||||
- name: Update Chrome manifest
|
||||
run: |
|
||||
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
|
||||
bun x json -I -f manifest/chrome.json -e "this.version='${VERSION_WITHOUT_PRERELEASE}'"
|
||||
run: bun x json -I -f manifest/chrome.json -e "this.version='${{ steps.version.outputs.stable_version }}'"
|
||||
|
||||
- name: Update Firefox manifest
|
||||
run: |
|
||||
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
|
||||
bun x json -I -f manifest/firefox.json -e "this.version='${VERSION_WITHOUT_PRERELEASE}'"
|
||||
run: bun x json -I -f manifest/firefox.json -e "this.version='${{ steps.version.outputs.stable_version }}'"
|
||||
|
||||
- name: Update Safari manifest
|
||||
run: |
|
||||
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
|
||||
bun x json -I -f safari/Mue\ Extension/Resources/manifest.json -e "this.version='${VERSION_WITHOUT_PRERELEASE}'"
|
||||
run: bun x json -I -f safari/Mue\ Extension/Resources/manifest.json -e "this.version='${{ steps.version.outputs.stable_version }}'"
|
||||
|
||||
- name: Update Safari Xcode project
|
||||
run: |
|
||||
VERSION_WITHOUT_PRERELEASE=$(echo "${{ steps.version.outputs.new_version }}" | sed 's/-.*$//')
|
||||
sed -i "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${VERSION_WITHOUT_PRERELEASE}/g" safari/Mue.xcodeproj/project.pbxproj
|
||||
run: sed -i "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = ${{ steps.version.outputs.stable_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
|
||||
run: sed -i "s/export const VERSION = '[^']*'/export const VERSION = '${{ steps.version.outputs.new_version }}'/" src/config/constants.js
|
||||
|
||||
- name: Commit version bump
|
||||
- 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 }}"
|
||||
|
||||
- name: Push changes
|
||||
run: |
|
||||
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 created: v${{ 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
|
||||
@@ -151,3 +132,9 @@ jobs:
|
||||
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
1
.gitignore
vendored
@@ -29,6 +29,7 @@ safari/DerivedData/
|
||||
safari/build/
|
||||
|
||||
# Files
|
||||
unused-translations.txt
|
||||
package-lock.json
|
||||
.stylelintcache
|
||||
yarn-error.log
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
bunx --bun commitlint --edit $1
|
||||
#!/bin/sh
|
||||
bunx --bun commitlint --edit "$1"
|
||||
|
||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What is Mue?
|
||||
|
||||
Mue is a browser extension (Chrome, Firefox, Safari) that replaces the new tab page with a customizable dashboard featuring widgets like clock, weather, quotes, greetings, quick links, and backgrounds. Built with React 19, Vite 7, and Manifest V3.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun install # Install dependencies (always use bun, never npm/yarn)
|
||||
bun run dev # Dev server with HMR (opens browser automatically)
|
||||
bun run dev:host # Dev server exposed on network
|
||||
bun run build # Production build for all browsers (Chrome, Firefox, Safari)
|
||||
bun run lint # Run ESLint + Stylelint
|
||||
bun run lint:fix # Auto-fix lint issues
|
||||
bun run pretty # Format with Prettier
|
||||
bun run translations # Sync all locale files from en_GB.json base
|
||||
bun run translations:percentages # Update translation completion stats
|
||||
bun run translations:unused # Find unused translation keys
|
||||
```
|
||||
|
||||
Build outputs: `build/chrome/`, `build/firefox/`, `safari/Mue Extension/Resources/`. Vite's `prepareBuilds` plugin (in `vite.config.mjs`) copies dist + manifests + icons into each browser folder and creates versioned zips.
|
||||
|
||||
There are no tests in this project.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Bootstrap Flow
|
||||
|
||||
1. `src/index.jsx` - Initializes i18n from localStorage language, sets up Sentry, runs data migrations, exposes global `window.t()`, renders `<ErrorBoundary><App/></ErrorBoundary>`
|
||||
2. `src/App.jsx` - `useAppSetup()` checks first-run state, calls `loadSettings()` (which applies theme, fonts, custom CSS to the DOM), listens for EventBus `'refresh'` events. Renders: `<TranslationProvider>` → `<Background>` + `<CustomWidgets>` + `<Widgets>` + `<Modals>`
|
||||
|
||||
### State Management (no Redux/Zustand)
|
||||
|
||||
All persistent state lives in **localStorage**. Components read from localStorage on mount and re-read when they receive EventBus refresh events. The only React Context is `TranslationContext` for i18n.
|
||||
|
||||
- `localStorage.getItem('key') === 'true'` is the standard boolean check pattern
|
||||
- Settings changes write to localStorage, then emit `EventBus.emit('refresh', 'category')` to notify widgets
|
||||
- `src/utils/settings/load.js` applies localStorage settings to the DOM (theme classes, injected style elements for custom fonts/CSS)
|
||||
|
||||
### EventBus (`src/utils/eventbus.js`)
|
||||
|
||||
Static class wrapping DOM CustomEvents. Primary communication mechanism between settings UI and widgets.
|
||||
|
||||
Key events:
|
||||
|
||||
- `'refresh'` with payload: `'quote'`, `'greeting'`, `'background'`, `'widgets'`, `'clock'`, `'other'` - triggers widget reload
|
||||
- `'languageChange'` with `{language: 'en_GB'}` - switches locale
|
||||
- `'modal'` with `'openMainModal'` - opens settings modal
|
||||
|
||||
Pattern: register in `useEffect`, clean up with `EventBus.off()` on unmount.
|
||||
|
||||
### Feature Organization (`src/features/`)
|
||||
|
||||
Each feature (background, time, quote, greeting, weather, search, quicklinks, message, navbar, stats, marketplace, welcome) follows this structure:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── index.jsx # Main component
|
||||
├── options/index.jsx # Settings panel UI
|
||||
├── hooks/ # Feature-specific hooks (useQuoteState, useQuoteLoader, etc.)
|
||||
├── components/ # Subcomponents
|
||||
├── api/ # Data fetching/processing
|
||||
└── scss/ # Styles
|
||||
```
|
||||
|
||||
The `misc` feature is special - it contains the modal system (`modals/Modals.jsx`), widget layout (`CustomWidgets.jsx`, `views/Widgets.jsx`), and the settings view (`views/Settings.jsx`).
|
||||
|
||||
### Modal & Settings System
|
||||
|
||||
`Modals.jsx` orchestrates four modals (main, welcome, update, apps). The main modal has three tabs: Settings, Discover (marketplace), Library. Deep-linking via URL hash: `#settings/appearance/fonts`, `#discover/quote_packs`.
|
||||
|
||||
### Global Variables (`src/config/variables.js`)
|
||||
|
||||
Singleton object holding `language` (i18n instance), `languagecode`, `stats`, and `constants`. Mutated during initialization. `variables.getMessage()` is dynamically set by TranslationContext.
|
||||
|
||||
### Hooks
|
||||
|
||||
- `useFrequencyInterval()` - configurable update intervals with visibility-aware pause/resume
|
||||
- `useCachedFetch()` - fetch with localStorage caching and TTL
|
||||
- `useT()` / `useTranslation()` - access translation function from context
|
||||
|
||||
### Path Aliases (configured in `vite.config.mjs`)
|
||||
|
||||
Use these instead of relative imports: `@/`, `components/`, `contexts/`, `hooks/`, `assets/`, `config/`, `features/`, `lib/`, `scss/`, `translations/`, `utils/`, `i18n/`
|
||||
|
||||
## Code Rules
|
||||
|
||||
### Do not add comments
|
||||
|
||||
Keep code self-explanatory. Use descriptive names instead of comments.
|
||||
|
||||
### Do not use emojis in code strings
|
||||
|
||||
No emojis in console logs, placeholders, or code strings. Exception: user-facing toast notifications may include emojis.
|
||||
|
||||
### All user-visible strings must use i18n
|
||||
|
||||
Never hardcode user-facing text. Use `const t = useT(); t('widgets.greeting.morning')` or `getMessage('key')`. Console logs don't need i18n.
|
||||
|
||||
### Translation workflow
|
||||
Edit `src/i18n/locales/en_GB.json` first (it's the base file), then run `bun run translations` to sync all other locales.
|
||||
|
||||
### Naming conventions
|
||||
Concise names without redundant prefixes. `const [open, setOpen] = useState(false)` not `const [isOpen, setIsOpen]`. Exception: `is` prefix is fine for boolean-returning functions (`isValid()`), `has` prefix for boolean properties.
|
||||
|
||||
### Branch strategy
|
||||
Three branches: `dev` (all PRs target here) → `beta` → `main`. Hotfix branches (`hotfix/*`) branch from `main`.
|
||||
|
||||
### Conventional commits
|
||||
Enforced by commitlint: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `style:`, `perf:`. Scopes encouraged: `feat(weather): add hourly forecast`.
|
||||
@@ -263,7 +263,7 @@ When beta is stable:
|
||||
7. Manually trigger store submission:
|
||||
```
|
||||
Actions → Submit → Run workflow
|
||||
- Enter version tag (e.g., v7.6.0)
|
||||
- Enter version tag (e.g., 7.6.0)
|
||||
```
|
||||
|
||||
#### 4. Emergency Hotfix
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:latest
|
||||
FROM oven/bun:1.1.42
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -8,4 +8,4 @@ RUN bun install
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["bun", "run", "dev:host"]
|
||||
CMD ["bun", "run", "dev:host"]
|
||||
|
||||
12
cypress.config.js
Normal file
12
cypress.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: "http://localhost:5173",
|
||||
viewportWidth: 1920,
|
||||
viewportHeight: 1080,
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
},
|
||||
},
|
||||
});
|
||||
131
cypress/e2e/basic.cy.js
Normal file
131
cypress/e2e/basic.cy.js
Normal file
@@ -0,0 +1,131 @@
|
||||
describe('Basic Features', () => {
|
||||
beforeEach(() => {
|
||||
// Bypass onboarding and enable features
|
||||
cy.visit('/', {
|
||||
onBeforeLoad: (win) => {
|
||||
win.localStorage.clear();
|
||||
win.localStorage.setItem('firstRun', 'true');
|
||||
win.localStorage.setItem('stats', 'true');
|
||||
win.localStorage.setItem('navbarHover', 'false');
|
||||
win.localStorage.setItem('notesEnabled', 'true');
|
||||
win.localStorage.setItem('searchBar', 'true');
|
||||
win.localStorage.setItem('voiceSearch', 'false');
|
||||
win.localStorage.setItem('language', 'en_GB');
|
||||
win.localStorage.setItem('theme', 'dark');
|
||||
win.localStorage.setItem('widgetStyle', 'new');
|
||||
win.localStorage.setItem('showWelcome', 'false');
|
||||
win.localStorage.setItem('view', 'true'); // Enable Maximise button
|
||||
win.localStorage.setItem('background', 'true'); // Enable Background for Maximise button
|
||||
win.localStorage.setItem('photoInformation', 'true'); // Enable Photo Information
|
||||
win.localStorage.setItem('backgroundType', 'api'); // Required for backgroundLoader
|
||||
|
||||
win.localStorage.setItem('quoteType', 'custom');
|
||||
win.localStorage.setItem('customQuote', 'Test Quote');
|
||||
win.localStorage.setItem('customQuoteAuthor', 'Test Author');
|
||||
|
||||
// Seed other widgets
|
||||
win.localStorage.setItem('greeting', 'true');
|
||||
win.localStorage.setItem('time', 'true');
|
||||
win.localStorage.setItem('date', 'true');
|
||||
|
||||
// Seed Quick Links with data so it renders with height
|
||||
win.localStorage.setItem('quicklinks', JSON.stringify([
|
||||
{ name: "Test Link", url: "https://example.com", key: "1" }
|
||||
]));
|
||||
win.localStorage.setItem('quicklinksenabled', 'true');
|
||||
|
||||
win.localStorage.setItem('message', 'true');
|
||||
win.localStorage.setItem('messages', '["Test Message"]');
|
||||
|
||||
win.localStorage.setItem('order', JSON.stringify(["greeting", "time", "quicklinks", "quote", "date", "message"]));
|
||||
}
|
||||
});
|
||||
|
||||
// Mock Background API
|
||||
cy.intercept('GET', '**/images/random*', { fixture: 'background.json' }).as('getBackground');
|
||||
});
|
||||
|
||||
it('should perform search', () => {
|
||||
const searchText = 'Cypress Test Search';
|
||||
cy.get('#searchtext').should('exist');
|
||||
cy.get('#searchtext').type(searchText);
|
||||
cy.get('#searchtext').should('have.value', searchText);
|
||||
});
|
||||
|
||||
it('should use Notes feature', () => {
|
||||
cy.get('.notes .navbarButton').click();
|
||||
const noteText = 'This is a test note';
|
||||
cy.get('.notesContainer textarea').should('be.visible');
|
||||
cy.get('.notesContainer textarea').clear().type(noteText);
|
||||
cy.reload();
|
||||
cy.get('.notes .navbarButton').click();
|
||||
cy.get('.notesContainer textarea').should('have.value', noteText);
|
||||
});
|
||||
|
||||
it('should maximise and unmaximise widgets', () => {
|
||||
cy.get('#widgets').should('not.have.css', 'display', 'none');
|
||||
cy.get('button[aria-label="Maximise"]').click();
|
||||
cy.get('#widgets').should('have.css', 'display', 'none');
|
||||
cy.get('button[aria-label="Maximise"]').click();
|
||||
cy.get('#widgets').should('not.have.css', 'display', 'none');
|
||||
});
|
||||
|
||||
it('should display photo information and background actions', () => {
|
||||
cy.wait('@getBackground');
|
||||
cy.get('.photoInformation', { timeout: 10000 }).should('exist').and('be.visible');
|
||||
cy.get('.photoInformation').trigger('mouseover');
|
||||
|
||||
// Check primary content
|
||||
cy.get('.photoInformation .primary-content').should('exist');
|
||||
|
||||
// Check Action Buttons
|
||||
cy.get('.photoInformation .buttons').should('exist');
|
||||
cy.get('.photoInformation .buttons svg').should('have.length.at.least', 3);
|
||||
|
||||
// Test Favourite Interaction
|
||||
cy.get('.photoInformation .buttons > .tooltip').eq(1).click({ force: true });
|
||||
});
|
||||
|
||||
it('should display quote link', () => {
|
||||
// Reload with offlineMode to ensure we get a guaranteed offline quote
|
||||
cy.visit('/', {
|
||||
onBeforeLoad: (win) => {
|
||||
win.localStorage.clear();
|
||||
win.localStorage.setItem('firstRun', 'true');
|
||||
win.localStorage.setItem('showWelcome', 'false');
|
||||
win.localStorage.setItem('widgetStyle', 'new');
|
||||
win.localStorage.setItem('offlineMode', 'true');
|
||||
win.localStorage.setItem('order', JSON.stringify(["quote"]));
|
||||
}
|
||||
});
|
||||
|
||||
cy.get('.quotediv').should('exist');
|
||||
cy.get('.quotediv .quote').should('not.be.empty');
|
||||
cy.get('.quotediv a').should('exist').and('have.attr', 'href').and('include', 'wikipedia');
|
||||
});
|
||||
|
||||
it('should have a refresh button', () => {
|
||||
cy.get('button[aria-label="Refresh"]').should('exist').and('be.visible');
|
||||
});
|
||||
|
||||
it('should display other dashboard widgets', () => {
|
||||
// Verify Greeting
|
||||
cy.get('.greeting').should('exist').and('be.visible');
|
||||
|
||||
// Verify Time (Clock)
|
||||
// Checks for either generic clock class or clock container
|
||||
cy.get('.clock-container').should('exist').and('be.visible');
|
||||
|
||||
// Verify Date
|
||||
cy.get('.date').should('exist').and('be.visible');
|
||||
|
||||
// Verify Quick Links
|
||||
// Checks for the container
|
||||
cy.get('.quicklinkscontainer').should('exist').and('be.visible');
|
||||
// Ensure it has content (the link we seeded)
|
||||
cy.get('.quicklinkscontainer a').should('have.length.at.least', 1);
|
||||
|
||||
// Verify Message
|
||||
cy.get('.message').should('exist').and('be.visible').and('contain', 'Test Message');
|
||||
});
|
||||
});
|
||||
48
cypress/e2e/welcome.cy.js
Normal file
48
cypress/e2e/welcome.cy.js
Normal file
@@ -0,0 +1,48 @@
|
||||
describe('Welcome Modal Flow', () => {
|
||||
beforeEach(() => {
|
||||
cy.clearLocalStorage();
|
||||
cy.visit('/');
|
||||
});
|
||||
|
||||
it('should complete the full onboarding flow', () => {
|
||||
// Step 1: Intro
|
||||
cy.contains('Welcome to Mue Tab', { timeout: 10000 }).should('be.visible');
|
||||
cy.contains('Next').click();
|
||||
|
||||
// Step 2: Language
|
||||
cy.contains('Choose your language').should('be.visible');
|
||||
cy.contains('Next').click();
|
||||
|
||||
// Step 3: Import Settings
|
||||
cy.get('.upload').should('be.visible');
|
||||
cy.contains('Next').click();
|
||||
|
||||
// Step 4: Theme
|
||||
cy.contains('Select a theme').should('be.visible');
|
||||
cy.contains('Dark').click();
|
||||
cy.get('body').should('have.class', 'dark');
|
||||
|
||||
cy.contains('Light').click();
|
||||
cy.get('body').should('not.have.class', 'dark');
|
||||
|
||||
cy.contains('Auto').click();
|
||||
cy.contains('Dark').click();
|
||||
cy.contains('Next').click();
|
||||
|
||||
// Step 5: Style
|
||||
cy.contains('Choose a style').should('be.visible');
|
||||
cy.contains('Modern').click();
|
||||
cy.contains('Next').click();
|
||||
|
||||
// Step 6: Privacy
|
||||
cy.contains('Privacy Options').should('be.visible');
|
||||
cy.contains('Next').click();
|
||||
|
||||
// Step 7: Final
|
||||
cy.contains('Final step').should('be.visible');
|
||||
cy.contains('Finish').click();
|
||||
|
||||
// Verify Dashboard
|
||||
cy.get('.greeting').should('be.visible');
|
||||
});
|
||||
});
|
||||
18
cypress/fixtures/background.json
Normal file
18
cypress/fixtures/background.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"file": "https://images.unsplash.com/photo-1472214103451-9374bd1c798e",
|
||||
"location": {
|
||||
"name": "Loch Lomond, United Kingdom",
|
||||
"latitude": 56.2466465,
|
||||
"longitude": -4.7208886
|
||||
},
|
||||
"photographer": "Adam Jang",
|
||||
"photographer_page": "https://unsplash.com/@adamjang",
|
||||
"camera": "Canon, EOS 70D",
|
||||
"views": 1500000,
|
||||
"downloads": 10000,
|
||||
"likes": 500,
|
||||
"category": "nature",
|
||||
"colour": "#262626",
|
||||
"blur_hash": "L25#t70000?b00?b?bof00D%?bof",
|
||||
"pun": "test_unique_id"
|
||||
}
|
||||
9
cypress/fixtures/quote.json
Normal file
9
cypress/fixtures/quote.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"quote": "The only way to do great work is to love what you do.",
|
||||
"author": "Steve Jobs",
|
||||
"category": "inspirational",
|
||||
"language": "en",
|
||||
"authorlink": "https://en.wikipedia.org/wiki/Steve_Jobs"
|
||||
}
|
||||
]
|
||||
24
cypress/support/commands.js
Normal file
24
cypress/support/commands.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// This example commands.js shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
19
cypress/support/e2e.js
Normal file
19
cypress/support/e2e.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// This example e2e.js is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
@@ -184,7 +184,7 @@ Before starting any release:
|
||||
- Check build artifacts
|
||||
- **Approve deployment** in Environments → production
|
||||
|
||||
4. **Review period** - Workflow waits for your approval (10 min deployment protection)
|
||||
4. **Wait 10 minutes** (cooldown period)
|
||||
|
||||
5. **Release completes**:
|
||||
- GitHub release published
|
||||
@@ -197,7 +197,7 @@ Before starting any release:
|
||||
|
||||
1. Go to **Actions** → **Submit** → **Run workflow**
|
||||
|
||||
2. Enter version tag: `v7.6.0` (include the 'v' prefix to match the release tag)
|
||||
2. Enter version tag: `7.6.0` (no 'v' prefix)
|
||||
|
||||
3. Click **Run workflow**
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
|
||||
export default [
|
||||
// Ignore patterns
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
@@ -18,7 +17,6 @@ export default [
|
||||
],
|
||||
},
|
||||
|
||||
// Base config for all JS/JSX files
|
||||
{
|
||||
files: ['**/*.{js,jsx,mjs}'],
|
||||
languageOptions: {
|
||||
@@ -26,7 +24,6 @@ export default [
|
||||
sourceType: 'module',
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
globals: {
|
||||
// Browser globals
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
navigator: 'readonly',
|
||||
@@ -48,7 +45,6 @@ export default [
|
||||
AbortController: 'readonly',
|
||||
btoa: 'readonly',
|
||||
atob: 'readonly',
|
||||
// Node globals for scripts
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
@@ -64,21 +60,17 @@ export default [
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// React specific rules
|
||||
'react/prop-types': 'off', // Using PropTypes is optional
|
||||
'react/react-in-jsx-scope': 'off', // Not needed with React 17+
|
||||
'react/jsx-uses-react': 'off', // Not needed with React 17+
|
||||
'react/prop-types': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-uses-react': 'off',
|
||||
|
||||
// General rules
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
|
||||
// Modern JS
|
||||
'prefer-const': 'warn',
|
||||
'no-var': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Prettier config (must be last to override other formatting rules)
|
||||
prettier,
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@/*": ["./*"],
|
||||
"i18n/*": ["./i18n/*"],
|
||||
"components/*": ["./components/*"],
|
||||
"hooks/*": ["./hooks/*"],
|
||||
"assets/*": ["./assets/*"],
|
||||
"config/*": ["./config/*"],
|
||||
"features/*": ["./features/*"],
|
||||
|
||||
8
manifest/_locales/ar/messages.json
Normal file
8
manifest/_locales/ar/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/arz/messages.json
Normal file
8
manifest/_locales/arz/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/az/messages.json
Normal file
8
manifest/_locales/az/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/azb/messages.json
Normal file
8
manifest/_locales/azb/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/de_DE/messages.json
Normal file
8
manifest/_locales/de_DE/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/el/messages.json
Normal file
8
manifest/_locales/el/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/et/messages.json
Normal file
8
manifest/_locales/et/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/fa/messages.json
Normal file
8
manifest/_locales/fa/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/hu/messages.json
Normal file
8
manifest/_locales/hu/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/ja/messages.json
Normal file
8
manifest/_locales/ja/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/lt/messages.json
Normal file
8
manifest/_locales/lt/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/lv/messages.json
Normal file
8
manifest/_locales/lv/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/peo/messages.json
Normal file
8
manifest/_locales/peo/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/pt/messages.json
Normal file
8
manifest/_locales/pt/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/sl/messages.json
Normal file
8
manifest/_locales/sl/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/sv/messages.json
Normal file
8
manifest/_locales/sv/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/ta/messages.json
Normal file
8
manifest/_locales/ta/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/uk/messages.json
Normal file
8
manifest/_locales/uk/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
8
manifest/_locales/vi/messages.json
Normal file
8
manifest/_locales/vi/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": {
|
||||
"message": "Mue"
|
||||
},
|
||||
"description": {
|
||||
"message": "Fast, open and free-to-use new tab page for modern browsers."
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_name__",
|
||||
"description": "__MSG_description__",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.1",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"permissions": ["search"],
|
||||
"permissions": ["search", "bookmarks"],
|
||||
"action": {
|
||||
"default_icon": "icons/128x128.png"
|
||||
},
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
"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.1",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"permissions": ["bookmarks"],
|
||||
"action": {
|
||||
"default_icon": "icons/128x128.png"
|
||||
},
|
||||
|
||||
17
package.json
17
package.json
@@ -9,7 +9,7 @@
|
||||
"homepage": "https://muetab.com",
|
||||
"bugs": "https://github.com/mue/mue/issues/new?assignees=&labels=bug&template=bug-report.md&title=%5BBUG%5D",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.1",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.1",
|
||||
"engines": {
|
||||
@@ -21,24 +21,21 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@eartharoid/i18n": "1.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@floating-ui/react-dom": "2.1.6",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/lexend-deca": "5.0.14",
|
||||
"@fontsource/montserrat": "5.0.19",
|
||||
"@mui/material": "7.3.7",
|
||||
"@sentry/react": "^10.36.0",
|
||||
"embla-carousel-autoplay": "8.6.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"fast-blurhash": "^1.1.4",
|
||||
"image-conversion": "^2.1.1",
|
||||
"mue": "file:",
|
||||
"react": "^19.2.3",
|
||||
"react-best-gradient-color-picker": "^3.0.14",
|
||||
"react-clock": "6.0.0",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-router": "^7.13.0",
|
||||
"react-toastify": "11.0.5",
|
||||
"use-debounce": "^10.1.0"
|
||||
},
|
||||
@@ -49,6 +46,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"adm-zip": "0.5.16",
|
||||
"cypress": "^15.10.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
@@ -66,8 +64,11 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:host": "vite --host",
|
||||
"test:e2e": "cypress open",
|
||||
"test:e2e:headless": "cypress run",
|
||||
"translations": "cd scripts && node updatetranslations.cjs",
|
||||
"translations:percentages": "node scripts/updateTranslationPercentages.cjs",
|
||||
"translations:unused": "node scripts/findUnusedTranslations.cjs",
|
||||
"build": "vite build",
|
||||
"pretty": "prettier --write \"./**/*.{js,jsx,json,scss,css}\"",
|
||||
"lint": "eslint \"./src/**/*.{js,jsx}\" && stylelint \"./src/**/*.{scss,css}\"",
|
||||
@@ -75,4 +76,4 @@
|
||||
"postinstall": "husky",
|
||||
"prepare": "husky"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"default_locale": "en",
|
||||
"name": "__MSG_name__",
|
||||
"description": "__MSG_description__",
|
||||
"version": "7.5.0",
|
||||
"version": "7.6.1",
|
||||
"homepage_url": "https://muetab.com",
|
||||
"permissions": ["search"],
|
||||
"chrome_url_overrides": {
|
||||
|
||||
@@ -27,7 +27,12 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
message = request?.userInfo?["message"]
|
||||
}
|
||||
|
||||
os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none")
|
||||
os_log(
|
||||
.default,
|
||||
"Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)",
|
||||
String(describing: message),
|
||||
profile?.uuidString ?? "none"
|
||||
)
|
||||
|
||||
let response = NSExtensionItem()
|
||||
if #available(iOS 15.0, macOS 11.0, *) {
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -289,7 +289,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -445,7 +445,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
@@ -486,7 +486,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 7.5.0;
|
||||
MARKETING_VERSION = 7.6.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"-framework",
|
||||
SafariServices,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
"colors": [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
"filename": "icon_16x16.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
"filename": "icon_16x16@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
"filename": "icon_32x32.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
"filename": "icon_32x32@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
"filename": "icon_128x128.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
"filename": "icon_128x128@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
"filename": "icon_256x256.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
"filename": "icon_256x256@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
"filename": "icon_512x512.png",
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
"filename": "icon_512x512@2x.png",
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
"idiom": "universal",
|
||||
"scale": "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
function show(enabled, useSettingsInsteadOfPreferences) {
|
||||
if (useSettingsInsteadOfPreferences) {
|
||||
document.getElementsByClassName('state-on')[0].innerText = "Extension is enabled and ready to use!";
|
||||
document.getElementsByClassName('state-off')[0].innerText = "Extension is disabled. Enable it in Safari Settings.";
|
||||
document.getElementsByClassName('state-unknown')[0].innerText = "Enable Mue in Safari Settings to get started.";
|
||||
document.getElementsByClassName('open-preferences')[0].innerText = "Open Safari Settings";
|
||||
}
|
||||
if (useSettingsInsteadOfPreferences) {
|
||||
document.getElementsByClassName('state-on')[0].innerText =
|
||||
'Extension is enabled and ready to use!';
|
||||
document.getElementsByClassName('state-off')[0].innerText =
|
||||
'Extension is disabled. Enable it in Safari Settings.';
|
||||
document.getElementsByClassName('state-unknown')[0].innerText =
|
||||
'Enable Mue in Safari Settings to get started.';
|
||||
document.getElementsByClassName('open-preferences')[0].innerText = 'Open Safari Settings';
|
||||
}
|
||||
|
||||
if (typeof enabled === "boolean") {
|
||||
document.body.classList.toggle(`state-on`, enabled);
|
||||
document.body.classList.toggle(`state-off`, !enabled);
|
||||
} else {
|
||||
document.body.classList.remove(`state-on`);
|
||||
document.body.classList.remove(`state-off`);
|
||||
}
|
||||
if (typeof enabled === 'boolean') {
|
||||
document.body.classList.toggle(`state-on`, enabled);
|
||||
document.body.classList.toggle(`state-off`, !enabled);
|
||||
} else {
|
||||
document.body.classList.remove(`state-on`);
|
||||
document.body.classList.remove(`state-off`);
|
||||
}
|
||||
}
|
||||
|
||||
function openPreferences() {
|
||||
webkit.messageHandlers.controller.postMessage("open-preferences");
|
||||
webkit.messageHandlers.controller.postMessage('open-preferences');
|
||||
}
|
||||
|
||||
document.querySelector("button.open-preferences").addEventListener("click", openPreferences);
|
||||
document.querySelector('button.open-preferences').addEventListener('click', openPreferences);
|
||||
|
||||
@@ -1,124 +1,133 @@
|
||||
* {
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--spacing: 20px;
|
||||
--text-color: #ffffff;
|
||||
--shadow: 0 4px 20px rgb(0 0 0 / 30%);
|
||||
--background-color: #0A0A0A;
|
||||
--spacing: 20px;
|
||||
--text-color: #fff;
|
||||
--shadow: 0 4px 20px rgb(0 0 0 / 30%);
|
||||
--background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family: 'Lexend Deca', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
'Lexend Deca',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--background-color);
|
||||
z-index: -1;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--background-color);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing) * 0.5);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
padding: var(--spacing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing) * 0.5);
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-color);
|
||||
padding: var(--spacing);
|
||||
}
|
||||
|
||||
img {
|
||||
filter: drop-shadow(var(--shadow));
|
||||
margin-bottom: 5px;
|
||||
filter: drop-shadow(var(--shadow));
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3em;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
letter-spacing: -0.5px;
|
||||
font-size: 3em;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2em;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
opacity: 0.95;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
|
||||
font-size: 1.2em;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
opacity: 0.95;
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status-container {
|
||||
margin: 10px 0;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 10px 0;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.status-container p {
|
||||
font-size: 1.1em;
|
||||
margin: 0;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 1.1em;
|
||||
margin: 0;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
body:not(.state-on, .state-off) :is(.state-on, .state-off) {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.state-on :is(.state-off, .state-unknown) {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.state-off :is(.state-on, .state-unknown) {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
padding: 12px 28px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow);
|
||||
margin-top: 5px;
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
padding: 12px 28px;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: var(--shadow);
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f0f0f0;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 25px rgb(0 0 0 / 40%);
|
||||
background: #f0f0f0;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 25px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,15 @@ class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHan
|
||||
|
||||
self.webView.configuration.userContentController.add(self, name: "controller")
|
||||
|
||||
self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!)
|
||||
let mainUrl = Bundle.main.url(forResource: "Main", withExtension: "html")!
|
||||
let resourceUrl = Bundle.main.resourceURL!
|
||||
self.webView.loadFileURL(mainUrl, allowingReadAccessTo: resourceUrl)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extensionBundleIdentifier) { (state, error) in
|
||||
SFSafariExtensionManager.getStateOfSafariExtension(
|
||||
withIdentifier: extensionBundleIdentifier
|
||||
) { (state, error) in
|
||||
guard let state = state, error == nil else {
|
||||
// Insert code to inform the user that something went wrong.
|
||||
return
|
||||
@@ -43,11 +47,11 @@ class ViewController: NSViewController, WKNavigationDelegate, WKScriptMessageHan
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
if (message.body as! String != "open-preferences") {
|
||||
return;
|
||||
guard let messageBody = message.body as? String, messageBody == "open-preferences" else {
|
||||
return
|
||||
}
|
||||
|
||||
SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { error in
|
||||
SFSafariApplication.showPreferencesForExtension(withIdentifier: extensionBundleIdentifier) { _ in
|
||||
DispatchQueue.main.async {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
|
||||
224
scripts/findUnusedTranslations.cjs
Normal file
224
scripts/findUnusedTranslations.cjs
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOCALE_FILE = path.join(__dirname, '../src/i18n/locales/en_GB.json');
|
||||
const ACHIEVEMENTS_FILE = path.join(__dirname, '../src/i18n/locales/achievements/en_GB.json');
|
||||
const SEARCH_DIR = path.join(__dirname, '../src');
|
||||
const FILE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
|
||||
|
||||
/**
|
||||
* Flatten nested JSON object into dot-notation keys
|
||||
* @param {Object} obj - The object to flatten
|
||||
* @param {String} prefix - The prefix for nested keys
|
||||
* @returns {Array} Array of flattened keys
|
||||
*/
|
||||
function flattenKeys(obj, prefix = '') {
|
||||
const keys = [];
|
||||
|
||||
for (const key in obj) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
|
||||
keys.push(...flattenKeys(obj[key], fullKey));
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
|
||||
function getAllFiles(dir, fileList = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (!file.startsWith('.') && file !== 'node_modules' && file !== 'dist' && file !== 'build') {
|
||||
getAllFiles(filePath, fileList);
|
||||
}
|
||||
} else {
|
||||
const ext = path.extname(file);
|
||||
if (FILE_EXTENSIONS.includes(ext)) {
|
||||
fileList.push(filePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files for usage of a translation key
|
||||
* Handles both direct usage and dynamic template literal construction
|
||||
* @param {String} key - The translation key to search for
|
||||
* @param {Array} files - Array of file paths to search
|
||||
* @param {Map} fileContentsCache - Cache of file contents
|
||||
* @returns {Boolean} True if the key is found in any file
|
||||
*/
|
||||
function isKeyUsed(key, files, fileContentsCache) {
|
||||
const keySegments = key.split('.');
|
||||
const patterns = [];
|
||||
|
||||
const escapedFullKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
patterns.push(new RegExp(`['"\`]${escapedFullKey}['"\`]`, 'g'));
|
||||
|
||||
if (keySegments.length >= 2) {
|
||||
const lastTwo = keySegments.slice(-2).map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\.');
|
||||
patterns.push(new RegExp(`['"\`]${lastTwo}['"\`]`, 'g'));
|
||||
}
|
||||
|
||||
if (keySegments.length >= 3) {
|
||||
const lastThree = keySegments.slice(-3).map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\.');
|
||||
patterns.push(new RegExp(`['"\`]${lastThree}['"\`]`, 'g'));
|
||||
}
|
||||
|
||||
if (keySegments.length >= 3) {
|
||||
const finalSegment = keySegments[keySegments.length - 1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
patterns.push(new RegExp(`\\.${finalSegment}['"\`]`, 'g'));
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
let content = fileContentsCache.get(file);
|
||||
if (content === undefined) {
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
fileContentsCache.set(file, content);
|
||||
} catch {
|
||||
fileContentsCache.set(file, '');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(content)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function main() {
|
||||
console.log('Finding unused translation keys...\n');
|
||||
|
||||
let translationKeys = [];
|
||||
|
||||
try {
|
||||
const localeContent = fs.readFileSync(LOCALE_FILE, 'utf-8');
|
||||
const localeData = JSON.parse(localeContent);
|
||||
translationKeys = flattenKeys(localeData);
|
||||
console.log(`Found ${translationKeys.length} translation keys in en_GB.json`);
|
||||
} catch (error) {
|
||||
console.error(`Error reading locale file: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(ACHIEVEMENTS_FILE)) {
|
||||
const achievementsContent = fs.readFileSync(ACHIEVEMENTS_FILE, 'utf-8');
|
||||
const achievementsData = JSON.parse(achievementsContent);
|
||||
const achievementKeys = flattenKeys(achievementsData, 'achievements');
|
||||
translationKeys.push(...achievementKeys);
|
||||
console.log(`Found ${achievementKeys.length} achievement keys in achievements/en_GB.json`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not read achievements file: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log(`\nTotal keys to check: ${translationKeys.length}`);
|
||||
|
||||
console.log('Scanning source files...');
|
||||
const files = getAllFiles(SEARCH_DIR);
|
||||
console.log(`Found ${files.length} files to search\n`);
|
||||
|
||||
const fileContentsCache = new Map();
|
||||
|
||||
const unusedKeys = [];
|
||||
const usedKeys = [];
|
||||
|
||||
console.log('Searching for key usage (including template literals)...');
|
||||
|
||||
let processed = 0;
|
||||
const totalKeys = translationKeys.length;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const key of translationKeys) {
|
||||
processed++;
|
||||
|
||||
if (processed % 10 === 0 || processed === totalKeys) {
|
||||
const percent = Math.round(processed / totalKeys * 100);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
process.stdout.write(`\r Progress: ${processed}/${totalKeys} (${percent}%) - ${elapsed}s elapsed`);
|
||||
}
|
||||
|
||||
if (isKeyUsed(key, files, fileContentsCache)) {
|
||||
usedKeys.push(key);
|
||||
} else {
|
||||
unusedKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\r Progress: ${totalKeys}/${totalKeys} (100%) - ${totalTime}s total \n`);
|
||||
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('RESULTS');
|
||||
console.log('='.repeat(70) + '\n');
|
||||
|
||||
console.log(`Used keys: ${usedKeys.length}`);
|
||||
console.log(`Unused keys: ${unusedKeys.length}`);
|
||||
console.log(`Usage rate: ${((usedKeys.length / totalKeys) * 100).toFixed(2)}%\n`);
|
||||
|
||||
if (unusedKeys.length > 0) {
|
||||
console.log('Unused translation keys:\n');
|
||||
|
||||
const grouped = {};
|
||||
unusedKeys.forEach(key => {
|
||||
const topLevel = key.split('.')[0];
|
||||
if (!grouped[topLevel]) {
|
||||
grouped[topLevel] = [];
|
||||
}
|
||||
grouped[topLevel].push(key);
|
||||
});
|
||||
|
||||
Object.keys(grouped).sort().forEach(category => {
|
||||
console.log(`\n ${category}:`);
|
||||
grouped[category].sort().forEach(key => {
|
||||
console.log(` - ${key}`);
|
||||
});
|
||||
});
|
||||
|
||||
const outputFile = path.join(__dirname, '../unused-translations.txt');
|
||||
const outputContent = [
|
||||
'# Unused Translation Keys',
|
||||
`# Generated: ${new Date().toISOString()}`,
|
||||
`# Total unused: ${unusedKeys.length}`,
|
||||
`# Note: This script checks for full keys and partial keys (last 2-3 segments)`,
|
||||
`# to catch dynamic template literal usage like \`\${PREFIX}.key\``,
|
||||
'',
|
||||
...unusedKeys.sort()
|
||||
].join('\n');
|
||||
|
||||
fs.writeFileSync(outputFile, outputContent, 'utf-8');
|
||||
console.log(`\nFull list saved to: ${path.relative(process.cwd(), outputFile)}`);
|
||||
} else {
|
||||
console.log('No unused translation keys found!');
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(70) + '\n');
|
||||
|
||||
if (unusedKeys.length > 0) {
|
||||
console.log('Tip: You can safely remove these keys from your translation files to reduce bundle size.');
|
||||
console.log('Note: Some keys might be used dynamically - review before removing!');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -2,7 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
// Language code mappings between Weblate and Mue
|
||||
const CODE_MAPPINGS = {
|
||||
de: 'de_DE',
|
||||
id: 'id_ID',
|
||||
@@ -69,7 +68,7 @@ async function updateTranslationPercentages() {
|
||||
fs.writeFileSync(outputPath, JSON.stringify(percentages, null, 2));
|
||||
fs.appendFileSync(outputPath, '\n');
|
||||
|
||||
console.log(`✓ Translation percentages updated successfully!`);
|
||||
console.log(`Translation percentages updated successfully!`);
|
||||
console.log(` Total languages: ${Object.keys(percentages).length}`);
|
||||
console.log(` Output: ${outputPath}`);
|
||||
|
||||
|
||||
@@ -16,13 +16,12 @@ const compareAndRemoveKeys = (json1, json2) => {
|
||||
|
||||
const localesDir = path.join(__dirname, '../src/i18n/locales');
|
||||
const achievementsDir = path.join(localesDir, 'achievements');
|
||||
const manifestLocalesDir = path.join(__dirname, '../manifest/_locales');
|
||||
|
||||
// Check if the locales directory exists, if not, create it
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
fs.mkdirSync(localesDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if the achievements directory exists, if not, create it
|
||||
if (!fs.existsSync(achievementsDir)) {
|
||||
fs.mkdirSync(achievementsDir, { recursive: true });
|
||||
}
|
||||
@@ -73,7 +72,6 @@ fs.readdirSync(achievementsDir).forEach((file) => {
|
||||
const locales = fs.readdirSync(localesDir);
|
||||
locales.forEach((locale) => {
|
||||
if (!fs.existsSync(path.join(achievementsDir, locale))) {
|
||||
// ignore directories
|
||||
if (fs.lstatSync(path.join(localesDir, locale)).isDirectory()) {
|
||||
return;
|
||||
}
|
||||
@@ -83,3 +81,30 @@ fs.readdirSync(achievementsDir).forEach((file) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Syncing manifest/_locales with src/i18n/locales...');
|
||||
const enManifestPath = path.join(manifestLocalesDir, 'en', 'messages.json');
|
||||
|
||||
if (!fs.existsSync(enManifestPath)) {
|
||||
console.error(`English manifest file does not exist at '${enManifestPath}'`);
|
||||
} else {
|
||||
const enManifest = fs.readFileSync(enManifestPath, 'utf8');
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir).filter((file) => {
|
||||
return !fs.lstatSync(path.join(localesDir, file)).isDirectory() && file.endsWith('.json');
|
||||
});
|
||||
|
||||
localeFiles.forEach((localeFile) => {
|
||||
const localeCode = localeFile.replace('.json', '');
|
||||
const manifestLocalePath = path.join(manifestLocalesDir, localeCode);
|
||||
const manifestMessagesPath = path.join(manifestLocalePath, 'messages.json');
|
||||
|
||||
if (!fs.existsSync(manifestLocalePath)) {
|
||||
console.log(`Creating missing locale: ${localeCode}`);
|
||||
fs.mkdirSync(manifestLocalePath, { recursive: true });
|
||||
fs.writeFileSync(manifestMessagesPath, enManifest);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Manifest locales sync complete!');
|
||||
}
|
||||
|
||||
19
src/App.jsx
19
src/App.jsx
@@ -1,15 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import Background from 'features/background/Background';
|
||||
import Widgets from 'features/misc/views/Widgets';
|
||||
import Modals from 'features/misc/modals/Modals';
|
||||
import CustomWidgets from 'features/misc/CustomWidgets';
|
||||
import { loadSettings, moveSettings } from 'utils/settings';
|
||||
import EventBus from 'utils/eventbus';
|
||||
import variables from 'config/variables';
|
||||
import { TranslationProvider } from 'contexts/TranslationContext';
|
||||
import { registerAllHandlers } from 'utils/marketplace/registerHandlers';
|
||||
import { installDefaultPacks } from 'utils/marketplace/installDefaultPacks';
|
||||
|
||||
const useAppSetup = () => {
|
||||
useEffect(() => {
|
||||
registerAllHandlers();
|
||||
|
||||
const firstRun = localStorage.getItem('firstRun');
|
||||
const stats = localStorage.getItem('stats');
|
||||
|
||||
@@ -20,8 +26,10 @@ const useAppSetup = () => {
|
||||
|
||||
loadSettings();
|
||||
|
||||
installDefaultPacks();
|
||||
|
||||
const refreshHandler = (data) => {
|
||||
if (data === 'other') {
|
||||
if (data === 'other' || data === 'greeting' || data === 'clock' || data === 'quote') {
|
||||
loadSettings(true);
|
||||
}
|
||||
};
|
||||
@@ -55,11 +63,15 @@ const App = () => {
|
||||
|
||||
useAppSetup();
|
||||
|
||||
const languagecode = localStorage.getItem('language') || 'en_GB';
|
||||
const languagecode = variables.languagecode || localStorage.getItem('language') || 'en_GB';
|
||||
|
||||
return (
|
||||
<TranslationProvider initialLanguage={languagecode}>
|
||||
<TranslationProvider
|
||||
initialLanguage={languagecode}
|
||||
initialTranslations={variables.language?.messages || {}}
|
||||
>
|
||||
{showBackground && <Background />}
|
||||
<CustomWidgets />
|
||||
<ToastContainer
|
||||
position="top-center"
|
||||
autoClose={toastDisplayTime}
|
||||
@@ -71,6 +83,7 @@ const App = () => {
|
||||
<Widgets />
|
||||
<Modals />
|
||||
</div>
|
||||
<Outlet />
|
||||
</TranslationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { captureException } from '@sentry/react';
|
||||
import variables from 'config/variables';
|
||||
|
||||
class ErrorBoundary extends PureComponent {
|
||||
constructor(props) {
|
||||
@@ -26,21 +27,35 @@ class ErrorBoundary extends PureComponent {
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
const title = variables.getMessage
|
||||
? variables.getMessage('error_boundary.title')
|
||||
: 'An error occurred';
|
||||
const message = variables.getMessage
|
||||
? variables.getMessage('error_boundary.message')
|
||||
: 'Something went wrong. Please try refreshing the page.';
|
||||
const reportButton = variables.getMessage
|
||||
? variables.getMessage('error_boundary.report_button')
|
||||
: 'Report Error';
|
||||
const sentSuccessfully = variables.getMessage
|
||||
? variables.getMessage('error_boundary.sent_successfully')
|
||||
: 'Report sent successfully';
|
||||
const supportDiscord = variables.getMessage
|
||||
? variables.getMessage('error_boundary.support_discord')
|
||||
: 'Get Support on Discord';
|
||||
|
||||
return (
|
||||
<div className="criticalError">
|
||||
<div className="criticalError-message">
|
||||
<h1>A critical error has occurred</h1>
|
||||
<p>
|
||||
The new tab page could not be loaded. Please uninstall the extension and try again.
|
||||
</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{message}</p>
|
||||
<div className="criticalError-actions">
|
||||
{this.state.showReport ? (
|
||||
<button onClick={() => this.reportError()}>Report Issue</button>
|
||||
<button onClick={() => this.reportError()}>{reportButton}</button>
|
||||
) : (
|
||||
<p>Sent Successfully</p>
|
||||
<p>{sentSuccessfully}</p>
|
||||
)}
|
||||
<a href="https://discord.gg/zv8C9F8" target="_blank" rel="noreferrer">
|
||||
Support Discord
|
||||
{supportDiscord}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,71 +1,229 @@
|
||||
import variables from 'config/variables';
|
||||
import { useT } from 'contexts';
|
||||
|
||||
import { useState, memo } from 'react';
|
||||
import { TextareaAutosize } from '@mui/material';
|
||||
import { useState, memo, useEffect } from 'react';
|
||||
import { MdAddLink, MdClose } from 'react-icons/md';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { Button } from 'components/Elements';
|
||||
import { Dropdown, Text } from 'components/Form/Settings';
|
||||
import { IconService } from 'utils/quicklinks';
|
||||
|
||||
import './AddModal.scss';
|
||||
|
||||
function AddModal({ urlError, iconError, addLink, closeModal, edit, editData, editLink }) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState(edit ? editData.name : '');
|
||||
const [url, setUrl] = useState(edit ? editData.url : '');
|
||||
const [icon, setIcon] = useState(edit ? editData.icon : '');
|
||||
const [iconType, setIconType] = useState(edit && editData.iconType ? editData.iconType : 'auto');
|
||||
const [iconData, setIconData] = useState(edit && editData.iconData ? editData.iconData : null);
|
||||
const [iconPreview, setIconPreview] = useState(null);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const [suggestedName, setSuggestedName] = useState('');
|
||||
const [resetKey, setResetKey] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!edit) {
|
||||
localStorage.removeItem('quicklink_modal_name');
|
||||
localStorage.removeItem('quicklink_modal_url');
|
||||
localStorage.removeItem('quicklink_modal_iconType');
|
||||
localStorage.removeItem('quicklink_modal_icon_url');
|
||||
localStorage.removeItem('quicklink_modal_emoji');
|
||||
|
||||
setName('');
|
||||
setUrl('');
|
||||
setIcon('');
|
||||
setIconType('auto');
|
||||
setIconData(null);
|
||||
setIconPreview(null);
|
||||
setUploadError('');
|
||||
setSuggestedName('');
|
||||
|
||||
setResetKey(Date.now());
|
||||
}
|
||||
}, [edit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (name || !url) {
|
||||
setSuggestedName('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let urlToTest = url;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
urlToTest = 'https://' + url;
|
||||
}
|
||||
|
||||
const domain = new URL(urlToTest).hostname;
|
||||
if (domain) {
|
||||
const parts = domain.split('.');
|
||||
let name = parts[0];
|
||||
if (parts.length > 2 && parts[parts.length - 2] === 'co') {
|
||||
name = parts[parts.length - 3];
|
||||
}
|
||||
setSuggestedName(name);
|
||||
}
|
||||
} catch (e) {
|
||||
setSuggestedName('');
|
||||
}
|
||||
}, [url, name]);
|
||||
|
||||
const handleIconUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const dataUrl = await IconService.uploadCustomIcon(file);
|
||||
setIconData(dataUrl);
|
||||
setIconPreview(dataUrl);
|
||||
setUploadError('');
|
||||
} catch (e) {
|
||||
setUploadError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const finalName = name || suggestedName || '';
|
||||
|
||||
if (edit) {
|
||||
editLink(editData, finalName, url, icon, iconType, iconData);
|
||||
} else {
|
||||
addLink(finalName, url, icon, iconType, iconData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="addLinkModal">
|
||||
<div className="addLinkModal" onKeyDown={handleKeyDown}>
|
||||
<div className="shareHeader">
|
||||
<span className="title">
|
||||
{edit
|
||||
? variables.getMessage('widgets.quicklinks.edit')
|
||||
: variables.getMessage('widgets.quicklinks.new')}
|
||||
{edit ? t('widgets.quicklinks.edit') : t('widgets.quicklinks.new')}
|
||||
</span>
|
||||
<Tooltip title={variables.getMessage('modals.welcome.buttons.close')}>
|
||||
<Tooltip title={t('modals.welcome.buttons.close')}>
|
||||
<div className="close" onClick={() => closeModal()}>
|
||||
<MdClose />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="quicklinkModalTextbox">
|
||||
<TextareaAutosize
|
||||
maxRows={1}
|
||||
placeholder={variables.getMessage('widgets.quicklinks.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
style={{ gridColumn: 'span 2' }}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
maxRows={10}
|
||||
placeholder={variables.getMessage('widgets.quicklinks.url')}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
/>
|
||||
<TextareaAutosize
|
||||
maxRows={10}
|
||||
maxLines={1}
|
||||
placeholder={variables.getMessage('widgets.quicklinks.icon')}
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
|
||||
/>
|
||||
<div className="addLinkModal-row">
|
||||
<div className="addLinkModal-field">
|
||||
<div className="addLinkModal-labelRow">
|
||||
<label className="addLinkModal-label">{t('widgets.quicklinks.name')}</label>
|
||||
{suggestedName && !name && (
|
||||
<span className="addLinkModal-suggestedText">
|
||||
{t('widgets.quicklinks.suggested', { name: suggestedName })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Text
|
||||
key={`name-${resetKey}`}
|
||||
name="quicklink_modal_name"
|
||||
noSetting={true}
|
||||
onChange={(value) => setName(value)}
|
||||
placeholder={suggestedName || t('widgets.quicklinks.name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="addLinkModal-field">
|
||||
<label className="addLinkModal-label">
|
||||
{t('widgets.quicklinks.url')}
|
||||
<span className="addLinkModal-required">*</span>
|
||||
</label>
|
||||
<Text
|
||||
key={`url-${resetKey}`}
|
||||
name="quicklink_modal_url"
|
||||
noSetting={true}
|
||||
onChange={(value) => {
|
||||
let finalValue = value;
|
||||
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
|
||||
finalValue = 'https://' + value;
|
||||
}
|
||||
setUrl(finalValue);
|
||||
}}
|
||||
placeholder={t('widgets.quicklinks.url_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="addLinkModal-dropdownWrapper">
|
||||
<Dropdown
|
||||
label={t('widgets.quicklinks.icon_type_label')}
|
||||
name="quicklink_modal_iconType"
|
||||
noSetting={true}
|
||||
onChange={(value) => setIconType(value)}
|
||||
items={[
|
||||
{ value: 'auto', text: t('widgets.quicklinks.icon_type_auto') },
|
||||
{ value: 'custom_url', text: t('widgets.quicklinks.icon_type_custom_url') },
|
||||
{ value: 'custom_upload', text: t('widgets.quicklinks.icon_type_upload') },
|
||||
{ value: 'emoji', text: t('widgets.quicklinks.icon_type_emoji') },
|
||||
{ value: 'letter', text: t('widgets.quicklinks.icon_type_letter') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{iconType === 'custom_url' && (
|
||||
<div className="text-field" style={{ gridColumn: 'span 2' }}>
|
||||
<Text
|
||||
key={`icon-url-${resetKey}`}
|
||||
name="quicklink_modal_icon_url"
|
||||
noSetting={true}
|
||||
onChange={(value) => setIcon(value)}
|
||||
placeholder={t('widgets.quicklinks.icon_url_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{iconType === 'custom_upload' && (
|
||||
<div className="text-field" style={{ gridColumn: 'span 2' }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleIconUpload}
|
||||
style={{ padding: '10px 0' }}
|
||||
/>
|
||||
{iconPreview && (
|
||||
<img
|
||||
src={iconPreview}
|
||||
alt={t('common.alt_text.preview')}
|
||||
style={{ width: '40px', height: '40px', marginTop: '8px', borderRadius: '4px' }}
|
||||
/>
|
||||
)}
|
||||
{uploadError && (
|
||||
<span className="dropdown-error" style={{ display: 'block', marginTop: '4px' }}>
|
||||
{uploadError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{iconType === 'emoji' && (
|
||||
<div className="text-field" style={{ gridColumn: 'span 2' }}>
|
||||
<Text
|
||||
key={`emoji-${resetKey}`}
|
||||
name="quicklink_modal_emoji"
|
||||
noSetting={true}
|
||||
onChange={(value) => setIcon(value)}
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="addFooter">
|
||||
<span className="dropdown-error">
|
||||
{iconError} {urlError}
|
||||
</span>
|
||||
{edit ? (
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => editLink(editData, name, url, icon)}
|
||||
icon={<MdAddLink />}
|
||||
label={variables.getMessage('modals.main.settings.sections.quicklinks.edit')}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => addLink(name, url, icon)}
|
||||
icon={<MdAddLink />}
|
||||
label={variables.getMessage('widgets.quicklinks.add')}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={handleSubmit}
|
||||
icon={<MdAddLink />}
|
||||
label={
|
||||
edit ? t('modals.main.settings.sections.quicklinks.edit') : t('widgets.quicklinks.add')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
81
src/components/Elements/AddModal/AddModal.scss
Normal file
81
src/components/Elements/AddModal/AddModal.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
@use 'scss/variables' as *;
|
||||
@use 'scss/mixins' as *;
|
||||
|
||||
.addLinkModal {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
|
||||
.addLinkModal-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.addLinkModal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-right: 35px;
|
||||
|
||||
.text-field-container {
|
||||
width: 100%;
|
||||
|
||||
.text-field {
|
||||
width: 100%;
|
||||
|
||||
.text-field-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addLinkModal-labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.addLinkModal-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
.addLinkModal-suggestedText {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
@include themed {
|
||||
color: t($link);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.addLinkModal-required {
|
||||
@include themed {
|
||||
color: t($link);
|
||||
}
|
||||
}
|
||||
|
||||
.addLinkModal-dropdownWrapper {
|
||||
grid-column: span 2;
|
||||
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,20 @@ import Tooltip from 'components/Elements/Tooltip/Tooltip';
|
||||
|
||||
const Button = forwardRef(
|
||||
(
|
||||
{ icon, label, type, iconPlacement, onClick, active, disabled, tooltipTitle, tooltipKey, href, style },
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
type,
|
||||
iconPlacement,
|
||||
onClick,
|
||||
active,
|
||||
disabled,
|
||||
tooltipTitle,
|
||||
tooltipKey,
|
||||
href,
|
||||
style,
|
||||
badge,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
let className;
|
||||
@@ -46,16 +59,24 @@ const Button = forwardRef(
|
||||
<button className={className} onClick={onClick} ref={ref} disabled={disabled} style={style}>
|
||||
{icon}
|
||||
{label}
|
||||
{badge !== undefined && badge !== null && <span className="btn-badge">{badge}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const linkButton = (
|
||||
<a className={className} onClick={onClick} ref={ref} disabled={disabled} href={href} style={style}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<a
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
href={href}
|
||||
style={style}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge && <span className="btn-badge">{badge}</span>}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -73,6 +94,7 @@ const Button = forwardRef(
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{badge && <span className="btn-badge">{badge}</span>}
|
||||
</a>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Suspense, lazy, useState, memo, useEffect } from 'react';
|
||||
import variables from 'config/variables';
|
||||
import { Suspense, lazy, useState, memo, useEffect, useRef, useMemo } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router';
|
||||
import { useT } from 'contexts';
|
||||
|
||||
import './scss/index.scss';
|
||||
import ModalLoader from './components/ModalLoader';
|
||||
import ModalTopBar from './components/ModalTopBar';
|
||||
import { TAB_TYPES } from './constants/tabConfig';
|
||||
import { updateHash, parseDeepLink } from 'utils/deepLinking';
|
||||
import { useRouterBridge } from '../../../router/RouterBridge';
|
||||
|
||||
const Settings = lazy(() => import('../../../features/misc/views/Settings'));
|
||||
const Library = lazy(() => import('../../../features/misc/views/Library'));
|
||||
const Discover = lazy(() => import('../../../features/misc/views/Discover'));
|
||||
|
||||
// Map tab types to their corresponding components
|
||||
const TAB_COMPONENTS = {
|
||||
[TAB_TYPES.SETTINGS]: Settings,
|
||||
[TAB_TYPES.LIBRARY]: Library,
|
||||
@@ -19,185 +20,224 @@ const TAB_COMPONENTS = {
|
||||
};
|
||||
|
||||
function MainModal({ modalClose, deepLinkData }) {
|
||||
// Initialize with deep link tab if provided, otherwise default to settings
|
||||
const initialTab = deepLinkData?.tab || TAB_TYPES.SETTINGS;
|
||||
const [currentTab, setCurrentTab] = useState(initialTab);
|
||||
const t = useT();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const { deepLinkData: routerDeepLinkData } = useRouterBridge();
|
||||
|
||||
// Use router-based deepLinkData if available, fallback to prop
|
||||
// Memoize to prevent infinite loops in useEffect
|
||||
const effectiveDeepLinkData = useMemo(
|
||||
() => routerDeepLinkData || deepLinkData,
|
||||
[location.pathname, deepLinkData],
|
||||
);
|
||||
|
||||
// Derive currentTab from router location instead of state
|
||||
const currentTab = effectiveDeepLinkData?.tab || TAB_TYPES.SETTINGS;
|
||||
|
||||
const [currentSection, setCurrentSection] = useState('');
|
||||
const [currentSectionName, setCurrentSectionName] = useState('');
|
||||
const [currentSubSection, setCurrentSubSection] = useState(deepLinkData?.subSection || null);
|
||||
const [currentSubSection, setCurrentSubSection] = useState(
|
||||
effectiveDeepLinkData?.subSection || null,
|
||||
);
|
||||
const [productView, setProductView] = useState(null);
|
||||
const [resetDiscoverToAll, setResetDiscoverToAll] = useState(false);
|
||||
const [navigationTrigger, setNavigationTrigger] = useState(null);
|
||||
const [iframeBreadcrumbs, setIframeBreadcrumbs] = useState([]);
|
||||
|
||||
// Clear product view when changing tabs
|
||||
const historyRef = useRef([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
|
||||
const updateNavButtons = () => {
|
||||
setCanGoBack(historyIndexRef.current > 0);
|
||||
setCanGoForward(historyIndexRef.current < historyRef.current.length - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setProductView(null);
|
||||
}, [currentTab]);
|
||||
|
||||
// Handle deep link updates (when modal opens via EventBus with new deep link)
|
||||
useEffect(() => {
|
||||
if (deepLinkData) {
|
||||
// Update tab if different
|
||||
if (deepLinkData.tab && deepLinkData.tab !== currentTab) {
|
||||
setCurrentTab(deepLinkData.tab);
|
||||
}
|
||||
|
||||
// Handle settings section navigation with subsection
|
||||
if (deepLinkData.tab === TAB_TYPES.SETTINGS && deepLinkData.section) {
|
||||
if (effectiveDeepLinkData) {
|
||||
if (effectiveDeepLinkData.tab === TAB_TYPES.SETTINGS && effectiveDeepLinkData.section) {
|
||||
setNavigationTrigger({
|
||||
type: 'settings-section',
|
||||
data: deepLinkData.section,
|
||||
data: effectiveDeepLinkData.section,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Set sub-section if present
|
||||
if (deepLinkData.subSection) {
|
||||
setCurrentSubSection(deepLinkData.subSection);
|
||||
if (effectiveDeepLinkData.subSection) {
|
||||
setCurrentSubSection(effectiveDeepLinkData.subSection);
|
||||
if (historyIndexRef.current >= 0) {
|
||||
historyRef.current[historyIndexRef.current] = {
|
||||
...historyRef.current[historyIndexRef.current],
|
||||
subSection: effectiveDeepLinkData.subSection,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [deepLinkData]);
|
||||
}, [effectiveDeepLinkData]);
|
||||
|
||||
// Clear hash when modal closes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// When modal unmounts, clear the hash
|
||||
if (window.location.hash) {
|
||||
window.history.replaceState(null, null, window.location.pathname);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// React to router location changes
|
||||
useEffect(() => {
|
||||
// Listen for browser back/forward navigation via popstate
|
||||
const handlePopState = () => {
|
||||
const linkData = window.location.hash ? parseDeepLink(window.location.hash) : null;
|
||||
if (effectiveDeepLinkData) {
|
||||
if (effectiveDeepLinkData.tab === TAB_TYPES.SETTINGS && effectiveDeepLinkData.section) {
|
||||
setNavigationTrigger({
|
||||
type: 'settings-section',
|
||||
data: effectiveDeepLinkData.section,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setCurrentSubSection(effectiveDeepLinkData.subSection || null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (linkData) {
|
||||
// Update tab if different
|
||||
if (linkData.tab && linkData.tab !== currentTab) {
|
||||
setCurrentTab(linkData.tab);
|
||||
}
|
||||
|
||||
// Handle settings section navigation
|
||||
if (linkData.tab === TAB_TYPES.SETTINGS && linkData.section) {
|
||||
setNavigationTrigger({
|
||||
type: 'settings-section',
|
||||
data: linkData.section,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Set sub-section if present in hash
|
||||
setCurrentSubSection(linkData.subSection || null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle product and collection navigation
|
||||
if (linkData.itemId && linkData.collection && linkData.fromCollection) {
|
||||
// Product viewed from within a collection
|
||||
// First set collection state, then navigate to product
|
||||
setNavigationTrigger({
|
||||
type: 'collection',
|
||||
data: linkData.collection,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
// Small delay to ensure collection state is set before navigating to product
|
||||
setTimeout(() => {
|
||||
setNavigationTrigger({
|
||||
type: 'product',
|
||||
data: {
|
||||
id: linkData.itemId,
|
||||
type: linkData.category,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, 100);
|
||||
} else if (linkData.itemId) {
|
||||
// Product navigation (standalone)
|
||||
if (
|
||||
effectiveDeepLinkData.itemId &&
|
||||
effectiveDeepLinkData.collection &&
|
||||
effectiveDeepLinkData.fromCollection
|
||||
) {
|
||||
setNavigationTrigger({
|
||||
type: 'collection',
|
||||
data: effectiveDeepLinkData.collection,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setTimeout(() => {
|
||||
setNavigationTrigger({
|
||||
type: 'product',
|
||||
data: {
|
||||
id: linkData.itemId,
|
||||
type: linkData.category,
|
||||
id: effectiveDeepLinkData.itemId,
|
||||
type: effectiveDeepLinkData.category,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (linkData.collection) {
|
||||
// Collection page navigation
|
||||
setNavigationTrigger({
|
||||
type: 'collection',
|
||||
data: linkData.collection,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
// Back to main view (clear collection state)
|
||||
setProductView(null);
|
||||
setNavigationTrigger({
|
||||
type: 'main',
|
||||
data: { clearCollection: true },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
} else if (effectiveDeepLinkData.itemId) {
|
||||
setNavigationTrigger({
|
||||
type: 'product',
|
||||
data: {
|
||||
id: effectiveDeepLinkData.itemId,
|
||||
type: effectiveDeepLinkData.category,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (effectiveDeepLinkData.collection) {
|
||||
setNavigationTrigger({
|
||||
type: 'collection',
|
||||
data: effectiveDeepLinkData.collection,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
setProductView(null);
|
||||
setNavigationTrigger({
|
||||
type: 'main',
|
||||
data: { clearCollection: true },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [currentTab]);
|
||||
}
|
||||
}, [effectiveDeepLinkData, currentTab]);
|
||||
|
||||
const handleChangeTab = (newTab) => {
|
||||
setCurrentTab(newTab);
|
||||
// Update URL hash when tab changes
|
||||
historyRef.current = [];
|
||||
historyIndexRef.current = -1;
|
||||
updateNavButtons();
|
||||
if (newTab === TAB_TYPES.DISCOVER) {
|
||||
updateHash(`#${newTab}/all`);
|
||||
const section = effectiveDeepLinkData?.category || effectiveDeepLinkData?.section || 'all';
|
||||
const itemId = effectiveDeepLinkData?.itemId ? `/${effectiveDeepLinkData.itemId}` : '';
|
||||
navigate(`/${newTab}/${section}${itemId}`);
|
||||
} else if (newTab === TAB_TYPES.LIBRARY) {
|
||||
updateHash(`#${newTab}/added`);
|
||||
navigate(`/${newTab}/added`);
|
||||
} else {
|
||||
updateHash(`#${newTab}`);
|
||||
navigate(`/${newTab}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionChange = (section, sectionName) => {
|
||||
setCurrentSection(section);
|
||||
setCurrentSectionName(sectionName);
|
||||
// Clear sub-section when changing sections
|
||||
setCurrentSubSection(null);
|
||||
// Update URL hash when section changes
|
||||
// Only reset subsection if we're actually changing to a different section
|
||||
// Don't reset on initial section set (when currentSectionName is empty)
|
||||
if (currentSectionName !== '' && currentSectionName !== sectionName) {
|
||||
setCurrentSubSection(null);
|
||||
}
|
||||
const entry = {
|
||||
section,
|
||||
sectionName,
|
||||
subSection:
|
||||
currentSectionName === '' || currentSectionName === sectionName ? currentSubSection : null,
|
||||
};
|
||||
const current = historyRef.current[historyIndexRef.current];
|
||||
if (
|
||||
!current ||
|
||||
current.sectionName !== sectionName ||
|
||||
current.subSection !== entry.subSection
|
||||
) {
|
||||
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1).concat(entry);
|
||||
historyIndexRef.current = historyRef.current.length - 1;
|
||||
updateNavButtons();
|
||||
}
|
||||
if (currentTab === TAB_TYPES.DISCOVER) {
|
||||
// For Discover tab, update with the section type
|
||||
// Don't navigate away if we're viewing a specific item
|
||||
if (effectiveDeepLinkData?.itemId) {
|
||||
return;
|
||||
}
|
||||
const sectionMap = {
|
||||
[variables.getMessage('modals.main.marketplace.all')]: 'all',
|
||||
[variables.getMessage('modals.main.marketplace.photo_packs')]: 'photo_packs',
|
||||
[variables.getMessage('modals.main.marketplace.quote_packs')]: 'quote_packs',
|
||||
[variables.getMessage('modals.main.marketplace.preset_settings')]: 'preset_settings',
|
||||
[variables.getMessage('modals.main.marketplace.collections')]: 'collections',
|
||||
[t('modals.main.marketplace.all')]: 'all',
|
||||
[t('modals.main.marketplace.photo_packs')]: 'photo_packs',
|
||||
[t('modals.main.marketplace.quote_packs')]: 'quote_packs',
|
||||
[t('modals.main.marketplace.preset_settings')]: 'preset_settings',
|
||||
[t('modals.main.marketplace.collections')]: 'collections',
|
||||
};
|
||||
const sectionKey = sectionMap[section];
|
||||
if (sectionKey) {
|
||||
updateHash(`#${currentTab}/${sectionKey}`);
|
||||
navigate(`/${currentTab}/${sectionKey}`);
|
||||
}
|
||||
} else if (currentTab === TAB_TYPES.SETTINGS && sectionName) {
|
||||
// For Settings tab, update with the section name
|
||||
updateHash(`#${currentTab}/${sectionName}`, false);
|
||||
// Include subsection in hash if it exists and we're not changing sections
|
||||
const path =
|
||||
currentSubSection && (currentSectionName === '' || currentSectionName === sectionName)
|
||||
? `/${currentTab}/${sectionName}/${currentSubSection}`
|
||||
: `/${currentTab}/${sectionName}`;
|
||||
navigate(path, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubSectionChange = (subSection, sectionName) => {
|
||||
setCurrentSubSection(subSection);
|
||||
// Update URL hash when sub-section changes
|
||||
const effectiveSectionName = sectionName || currentSectionName;
|
||||
const entry = { section: currentSection, sectionName: effectiveSectionName, subSection };
|
||||
const current = historyRef.current[historyIndexRef.current];
|
||||
if (
|
||||
!current ||
|
||||
current.sectionName !== effectiveSectionName ||
|
||||
current.subSection !== subSection
|
||||
) {
|
||||
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1).concat(entry);
|
||||
historyIndexRef.current = historyRef.current.length - 1;
|
||||
updateNavButtons();
|
||||
}
|
||||
if (currentTab === TAB_TYPES.SETTINGS && sectionName) {
|
||||
if (subSection) {
|
||||
updateHash(`#${currentTab}/${sectionName}/${subSection}`);
|
||||
navigate(`/${currentTab}/${sectionName}/${subSection}`);
|
||||
} else {
|
||||
// Going back to section, remove sub-section from hash
|
||||
updateHash(`#${currentTab}/${sectionName}`);
|
||||
navigate(`/${currentTab}/${sectionName}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductView = (product) => {
|
||||
setProductView(product);
|
||||
// URL hash is already updated by child components (Browse.jsx)
|
||||
// Browser history automatically tracks hash changes
|
||||
};
|
||||
|
||||
const handleResetDiscoverToAll = () => {
|
||||
@@ -205,21 +245,39 @@ function MainModal({ modalClose, deepLinkData }) {
|
||||
setTimeout(() => setResetDiscoverToAll(false), 100);
|
||||
};
|
||||
|
||||
const restoreHistoryEntry = (entry) => {
|
||||
setCurrentSubSection(entry.subSection);
|
||||
if (entry.sectionName !== currentSectionName) {
|
||||
setCurrentSection(entry.section);
|
||||
setCurrentSectionName(entry.sectionName);
|
||||
setNavigationTrigger({
|
||||
type: 'settings-section',
|
||||
data: entry.sectionName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
if (currentTab === TAB_TYPES.SETTINGS) {
|
||||
const hash = entry.subSection
|
||||
? `#${currentTab}/${entry.sectionName}/${entry.subSection}`
|
||||
: `#${currentTab}/${entry.sectionName}`;
|
||||
window.history.replaceState(null, null, hash);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
// Clear iframe breadcrumbs when navigating back
|
||||
setIframeBreadcrumbs([]);
|
||||
window.history.back();
|
||||
if (historyIndexRef.current <= 0) return;
|
||||
historyIndexRef.current -= 1;
|
||||
updateNavButtons();
|
||||
restoreHistoryEntry(historyRef.current[historyIndexRef.current]);
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
window.history.forward();
|
||||
if (historyIndexRef.current >= historyRef.current.length - 1) return;
|
||||
historyIndexRef.current += 1;
|
||||
updateNavButtons();
|
||||
restoreHistoryEntry(historyRef.current[historyIndexRef.current]);
|
||||
};
|
||||
|
||||
// Browser manages history state, so we always show buttons enabled
|
||||
// Browser will handle whether there's actually history to go back/forward
|
||||
const canGoBack = true;
|
||||
const canGoForward = true;
|
||||
|
||||
const TabComponent = TAB_COMPONENTS[currentTab] || Settings;
|
||||
|
||||
return (
|
||||
@@ -244,7 +302,7 @@ function MainModal({ modalClose, deepLinkData }) {
|
||||
<TabComponent
|
||||
key={currentTab}
|
||||
changeTab={handleChangeTab}
|
||||
deepLinkData={deepLinkData}
|
||||
deepLinkData={effectiveDeepLinkData}
|
||||
currentTab={currentTab}
|
||||
onSectionChange={handleSectionChange}
|
||||
onSubSectionChange={handleSubSectionChange}
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { getIconComponent, DIVIDER_LABELS } from '../constants/tabConfig';
|
||||
|
||||
function Tab({ label, currentTab, onClick, navbarTab }) {
|
||||
function Tab({ label, currentTab, onClick, navbarTab, isCollapsed }) {
|
||||
const t = useT();
|
||||
const [isExperimental, setIsExperimental] = useState(true);
|
||||
const isExperimental = localStorage.getItem('experimental') !== 'false';
|
||||
|
||||
useEffect(() => {
|
||||
setIsExperimental(localStorage.getItem('experimental') !== 'false');
|
||||
}, []);
|
||||
|
||||
// Get the icon component for this label (label is already translated)
|
||||
const IconComponent = getIconComponent(label, { getMessage: t });
|
||||
|
||||
// Determine if this label should have a divider after it
|
||||
const hasDivider = DIVIDER_LABELS.some((key) => t(key) === label);
|
||||
|
||||
// Build className
|
||||
const baseClass = navbarTab ? 'navbar-item' : 'tab-list-item';
|
||||
const activeClass = navbarTab ? 'navbar-item-active' : 'tab-list-active';
|
||||
const className = `${baseClass}${currentTab === label ? ` ${activeClass}` : ''}`;
|
||||
|
||||
// Hide experimental tab if experimental mode is disabled
|
||||
const isExperimentalTab = label === t('modals.main.settings.sections.experimental.title');
|
||||
if (isExperimentalTab && !isExperimental) {
|
||||
return <hr />;
|
||||
@@ -29,10 +22,18 @@ function Tab({ label, currentTab, onClick, navbarTab }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={className} onClick={() => onClick(label)}>
|
||||
{IconComponent && <IconComponent />} <span>{label}</span>
|
||||
</button>
|
||||
{hasDivider && <hr />}
|
||||
{isCollapsed ? (
|
||||
<Tooltip title={label} placement="right">
|
||||
<button className={className} onClick={() => onClick(label)}>
|
||||
{IconComponent && <IconComponent />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button className={className} onClick={() => onClick(label)}>
|
||||
{IconComponent && <IconComponent />} <span>{label}</span>
|
||||
</button>
|
||||
)}
|
||||
{!isCollapsed && hasDivider && <hr />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
import variables from 'config/variables';
|
||||
import Tab from './Tab';
|
||||
import ReminderInfo from '../components/ReminderInfo';
|
||||
import SidebarToggle from '../components/SidebarToggle';
|
||||
import ErrorBoundary from '../../../../features/misc/modals/ErrorBoundary';
|
||||
import { TAB_TYPES } from '../constants/tabConfig';
|
||||
import { SearchInput } from 'components/Form/Settings';
|
||||
import EventBus from 'utils/eventbus';
|
||||
|
||||
const Tabs = ({
|
||||
children,
|
||||
@@ -18,7 +21,6 @@ const Tabs = ({
|
||||
}) => {
|
||||
const t = useT();
|
||||
|
||||
// Find initial section from deep link if available
|
||||
const getInitialSection = () => {
|
||||
if (deepLinkData?.section && sections) {
|
||||
const section = sections.find((s) => s.name === deepLinkData.section);
|
||||
@@ -29,6 +31,15 @@ const Tabs = ({
|
||||
};
|
||||
}
|
||||
}
|
||||
if (deepLinkData?.category && sections) {
|
||||
const section = sections.find((s) => s.name === deepLinkData.category);
|
||||
if (section) {
|
||||
return {
|
||||
label: t(section.label),
|
||||
name: section.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: children[0]?.props.label,
|
||||
name: children[0]?.props.name,
|
||||
@@ -36,70 +47,80 @@ const Tabs = ({
|
||||
};
|
||||
|
||||
const initial = getInitialSection();
|
||||
const [currentTab, setCurrentTab] = useState(initial.label);
|
||||
const [currentName, setCurrentName] = useState(initial.name);
|
||||
const [showReminder, setShowReminder] = useState(localStorage.getItem('showReminder') === 'true');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(
|
||||
localStorage.getItem('sidebarCollapsed') === 'true',
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const contentRef = useRef(null);
|
||||
|
||||
const currentTab = (() => {
|
||||
if (sections && currentName) {
|
||||
const section = sections.find((s) => s.name === currentName);
|
||||
if (section) {
|
||||
return t(section.label);
|
||||
}
|
||||
}
|
||||
const child = children.find((c) => c.props.name === currentName);
|
||||
return child?.props.label || children[0]?.props.label;
|
||||
})();
|
||||
|
||||
const handleTabClick = (tab, name) => {
|
||||
if (name !== currentName) {
|
||||
variables.stats.postEvent('tab', `Opened ${name}`);
|
||||
}
|
||||
|
||||
setCurrentTab(tab);
|
||||
setCurrentName(name);
|
||||
|
||||
// Scroll content to top when changing tabs
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
// Notify parent of section change with both label and name
|
||||
if (onSectionChange) {
|
||||
onSectionChange(tab, name);
|
||||
}
|
||||
};
|
||||
|
||||
// Notify parent of initial section on mount
|
||||
useEffect(() => {
|
||||
if (onSectionChange && currentTab) {
|
||||
onSectionChange(currentTab, currentName);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update labels when language changes
|
||||
// React to deep link changes (e.g., when navigating to a suggested pack from settings)
|
||||
useEffect(() => {
|
||||
if (sections && currentName) {
|
||||
const section = sections.find((s) => s.name === currentName);
|
||||
if (section) {
|
||||
const newLabel = t(section.label);
|
||||
setCurrentTab(newLabel);
|
||||
if (deepLinkData && sections) {
|
||||
const targetSection = deepLinkData.section || deepLinkData.category;
|
||||
if (targetSection) {
|
||||
const section = sections.find((s) => s.name === targetSection);
|
||||
if (section && section.name !== currentName) {
|
||||
setCurrentName(section.name);
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [t, sections, currentName]);
|
||||
}, [deepLinkData, sections, currentName]);
|
||||
|
||||
// Handle navigation trigger for settings sections (popstate)
|
||||
useEffect(() => {
|
||||
// useLayoutEffect is appropriate here for synchronous state updates before paint
|
||||
useLayoutEffect(() => {
|
||||
if (navigationTrigger?.type === 'settings-section' && sections) {
|
||||
const section = sections.find((s) => s.name === navigationTrigger.data);
|
||||
if (section) {
|
||||
const label = t(section.label);
|
||||
setCurrentTab(label);
|
||||
setCurrentName(section.name);
|
||||
// Scroll content to top when navigating via browser history
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [navigationTrigger, sections, t]);
|
||||
}, [navigationTrigger, sections]);
|
||||
|
||||
// Reset to first tab when requested
|
||||
useEffect(() => {
|
||||
// useLayoutEffect is appropriate here for synchronous state updates before paint
|
||||
useLayoutEffect(() => {
|
||||
if (resetToFirst) {
|
||||
setCurrentTab(children[0]?.props.label);
|
||||
setCurrentName(children[0]?.props.name);
|
||||
// Scroll content to top when resetting to first tab
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = 0;
|
||||
}
|
||||
@@ -109,40 +130,91 @@ const Tabs = ({
|
||||
}
|
||||
}, [resetToFirst]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowReminder = () => {
|
||||
localStorage.setItem('showReminder', 'true');
|
||||
setShowReminder(true);
|
||||
};
|
||||
|
||||
EventBus.on('showReminder', handleShowReminder);
|
||||
return () => EventBus.off('showReminder', handleShowReminder);
|
||||
}, []);
|
||||
|
||||
const handleHideReminder = () => {
|
||||
localStorage.setItem('showReminder', 'false');
|
||||
setShowReminder(false);
|
||||
};
|
||||
|
||||
// Show sidebar for Settings and Discover tabs
|
||||
const handleToggleSidebar = () => {
|
||||
const newState = !sidebarCollapsed;
|
||||
setSidebarCollapsed(newState);
|
||||
localStorage.setItem('sidebarCollapsed', newState.toString());
|
||||
};
|
||||
|
||||
const showSidebar = activeTab === TAB_TYPES.SETTINGS || activeTab === TAB_TYPES.DISCOVER;
|
||||
|
||||
const filteredChildren = children.filter((tab) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
return tab.props.label.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
||||
e.preventDefault();
|
||||
if (showSidebar) {
|
||||
setSidebarCollapsed((prev) => !prev);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyPress);
|
||||
return () => window.removeEventListener('keydown', handleKeyPress);
|
||||
}, [showSidebar]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
{showSidebar ? (
|
||||
<div className="modalSidebar">
|
||||
{children.map((tab, index) => (
|
||||
<div className={`modalSidebar ${sidebarCollapsed ? 'collapsed' : 'expanded'}`}>
|
||||
<div className="sidebarHeader">
|
||||
<SidebarToggle isCollapsed={sidebarCollapsed} onToggle={handleToggleSidebar} />
|
||||
{!sidebarCollapsed && activeTab === TAB_TYPES.SETTINGS && (
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('widgets.search')}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{filteredChildren.map((tab, index) => (
|
||||
<Tab
|
||||
key={index}
|
||||
currentTab={currentTab}
|
||||
label={tab.props.label}
|
||||
onClick={(nextTab) => handleTabClick(nextTab, tab.props.name)}
|
||||
navbarTab={navbar}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
))}
|
||||
<ReminderInfo isVisible={showReminder} onHide={handleHideReminder} />
|
||||
{searchQuery.trim() && filteredChildren.length === 0 && (
|
||||
<div className="sidebarEmptyState">{t('widgets.weather.not_found')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="modalTabContent" ref={contentRef}>
|
||||
{children.map((tab, index) => {
|
||||
if (tab.props.label !== currentTab) {
|
||||
return null;
|
||||
}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
|
||||
<ReminderInfo isVisible={showReminder} onHide={handleHideReminder} />
|
||||
<div className="modalTabContent" ref={contentRef}>
|
||||
{children.map((tab, index) => {
|
||||
if (tab.props.label !== currentTab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary key={`error-boundary-${index}`}>{tab.props.children}</ErrorBoundary>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<ErrorBoundary key={`error-boundary-${index}`}>{tab.props.children}</ErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import variables from 'config/variables';
|
||||
import { useT } from 'contexts';
|
||||
import SidebarSkeleton from './SidebarSkeleton';
|
||||
|
||||
const ModalLoader = ({ currentTab }) => (
|
||||
@@ -11,7 +11,7 @@ const ModalLoader = ({ currentTab }) => (
|
||||
<div className="emptyMessage">
|
||||
<div className="loaderHolder">
|
||||
<div id="loader"></div>
|
||||
<span className="subtitle">{variables.getMessage('modals.main.loading')}</span>
|
||||
<span className="subtitle">{t('modals.main.loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import variables from 'config/variables';
|
||||
import { Button } from 'components/Elements';
|
||||
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
||||
|
||||
const ModalNavbar = ({ currentTab, onChangeTab }) => (
|
||||
<div className="modalNavbar">
|
||||
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => (
|
||||
<Button
|
||||
key={tab}
|
||||
type="navigation"
|
||||
onClick={() => onChangeTab(tab)}
|
||||
icon={<Icon />}
|
||||
label={variables.getMessage(messageKey)}
|
||||
active={currentTab === tab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ModalNavbar;
|
||||
@@ -1,21 +1,33 @@
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { MdClose, MdChevronRight, MdArrowBack, MdArrowForward } from 'react-icons/md';
|
||||
import { Tooltip, Button } from 'components/Elements';
|
||||
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
|
||||
import { NAVBAR_BUTTONS, TAB_TYPES } from '../constants/tabConfig';
|
||||
import mueAboutIcon from 'assets/icons/mue_about.png';
|
||||
|
||||
// Map marketplace types to translation keys
|
||||
const MARKETPLACE_TYPE_TO_KEY = {
|
||||
photo_packs: 'modals.main.marketplace.photo_packs',
|
||||
'photo packs': 'modals.main.marketplace.photo_packs',
|
||||
photos: 'modals.main.marketplace.photo_packs',
|
||||
quote_packs: 'modals.main.marketplace.quote_packs',
|
||||
'quote packs': 'modals.main.marketplace.quote_packs',
|
||||
quotes: 'modals.main.marketplace.quote_packs',
|
||||
preset_settings: 'modals.main.marketplace.preset_settings',
|
||||
'preset settings': 'modals.main.marketplace.preset_settings',
|
||||
settings: 'modals.main.marketplace.preset_settings',
|
||||
collections: 'modals.main.marketplace.collections',
|
||||
all: 'modals.main.marketplace.all',
|
||||
};
|
||||
|
||||
const BREADCRUMB_LABEL_TO_CATEGORY = {
|
||||
'photo packs': 'photo_packs',
|
||||
'quote packs': 'quote_packs',
|
||||
'preset settings': 'preset_settings',
|
||||
collections: 'collections',
|
||||
marketplace: 'all',
|
||||
};
|
||||
|
||||
function ModalTopBar({
|
||||
currentTab,
|
||||
currentSection,
|
||||
@@ -24,6 +36,7 @@ function ModalTopBar({
|
||||
productView,
|
||||
iframeBreadcrumbs,
|
||||
onTabChange,
|
||||
onSectionChange,
|
||||
onSubSectionChange,
|
||||
onClose,
|
||||
onBack,
|
||||
@@ -32,21 +45,48 @@ function ModalTopBar({
|
||||
canGoForward,
|
||||
}) {
|
||||
const t = useT();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [installedCount, setInstalledCount] = useState(() => {
|
||||
try {
|
||||
const installed = JSON.parse(localStorage.getItem('installed')) || [];
|
||||
return installed.length;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateCount = () => {
|
||||
try {
|
||||
const installed = JSON.parse(localStorage.getItem('installed')) || [];
|
||||
setInstalledCount(installed.length);
|
||||
} catch (e) {
|
||||
setInstalledCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', updateCount);
|
||||
|
||||
window.addEventListener('installedAddonsChanged', updateCount);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', updateCount);
|
||||
window.removeEventListener('installedAddonsChanged', updateCount);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get the current tab label
|
||||
const currentTabButton = NAVBAR_BUTTONS.find(({ tab }) => tab === currentTab);
|
||||
const currentTabLabel = currentTabButton ? t(currentTabButton.messageKey) : '';
|
||||
|
||||
// Utility function to get translated sub-section label
|
||||
const getSubSectionLabel = (subSection, sectionName) => {
|
||||
if (!subSection || !sectionName) return subSection;
|
||||
if (!subSection || !sectionName) {
|
||||
return subSection;
|
||||
}
|
||||
|
||||
// Use the same translation pattern as the section components
|
||||
const translationKey = `modals.main.settings.sections.${sectionName}.${subSection}.title`;
|
||||
const translated = t(translationKey);
|
||||
|
||||
// If translation key is returned as-is or empty, it means translation doesn't exist
|
||||
// Fall back to capitalized sub-section name
|
||||
if (!translated || translated === translationKey) {
|
||||
return subSection.charAt(0).toUpperCase() + subSection.slice(1);
|
||||
}
|
||||
@@ -54,53 +94,78 @@ function ModalTopBar({
|
||||
return translated;
|
||||
};
|
||||
|
||||
// Determine breadcrumb path with click handlers
|
||||
const breadcrumbPath = [];
|
||||
|
||||
if (currentTabLabel) {
|
||||
breadcrumbPath.push({
|
||||
label: currentTabLabel,
|
||||
onClick: productView ? productView.onBackToAll : null, // Clickable if viewing a product
|
||||
onClick:
|
||||
(iframeBreadcrumbs && iframeBreadcrumbs.length > 0) || productView
|
||||
? () => {
|
||||
navigate('/discover/all');
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
// Check if we have iframe breadcrumbs (from Discover iframe)
|
||||
// If so, only use the last item (the item name) and keep our section
|
||||
if (iframeBreadcrumbs && iframeBreadcrumbs.length > 0) {
|
||||
// Get the last breadcrumb item (the item name)
|
||||
const lastCrumb = iframeBreadcrumbs[iframeBreadcrumbs.length - 1];
|
||||
const relevantCrumbs = iframeBreadcrumbs.slice(1);
|
||||
|
||||
relevantCrumbs.forEach((crumb, index) => {
|
||||
const isLast = index === relevantCrumbs.length - 1;
|
||||
|
||||
const lowerLabel = crumb.label.toLowerCase();
|
||||
const translationKey = MARKETPLACE_TYPE_TO_KEY[lowerLabel];
|
||||
const displayLabel = translationKey ? t(translationKey) : crumb.label;
|
||||
|
||||
const categoryKey = BREADCRUMB_LABEL_TO_CATEGORY[lowerLabel];
|
||||
|
||||
// Add current section if available and different from the last crumb
|
||||
if (currentSection && currentSection !== lastCrumb.label) {
|
||||
breadcrumbPath.push({
|
||||
label: currentSection,
|
||||
onClick: () => onBack(), // Clickable to go back
|
||||
});
|
||||
}
|
||||
label: displayLabel,
|
||||
onClick:
|
||||
crumb.clickable && !isLast && crumb.href
|
||||
? () => {
|
||||
const href = crumb.href;
|
||||
|
||||
// Add the item name from iframe
|
||||
breadcrumbPath.push({
|
||||
label: lastCrumb.label,
|
||||
onClick: null, // Current item - not clickable
|
||||
if (href.includes('type=')) {
|
||||
const urlParams = new URLSearchParams(href.split('?')[1]);
|
||||
const typeParam = urlParams.get('type');
|
||||
if (typeParam) {
|
||||
navigate(`/discover/${typeParam}`);
|
||||
}
|
||||
} else if (href.includes('/collections')) {
|
||||
navigate('/discover/collections');
|
||||
} else if (href.includes('/collection/')) {
|
||||
const collectionId = href.split('/collection/')[1]?.split('?')[0];
|
||||
if (collectionId) {
|
||||
navigate(`/discover/collection/${collectionId}`);
|
||||
}
|
||||
} else if (categoryKey) {
|
||||
navigate(`/discover/${categoryKey}`);
|
||||
} else if (href === '/marketplace' || href === '/marketplace/') {
|
||||
navigate('/discover/all');
|
||||
} else {
|
||||
const stepsBack = relevantCrumbs.length - index - 1;
|
||||
for (let i = 0; i < stepsBack; i++) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
});
|
||||
} else if (productView) {
|
||||
// If viewing a collection page itself (not a product within it)
|
||||
if (productView.isCollection) {
|
||||
// Show: Discover > Collection Name
|
||||
breadcrumbPath.push({
|
||||
label: productView.collectionTitle || productView.name,
|
||||
onClick: null, // Current page - not clickable
|
||||
onClick: null,
|
||||
});
|
||||
} else {
|
||||
// Viewing a product
|
||||
// Show: Discover > Collection/Category > Product
|
||||
if (productView.fromCollection && productView.collectionTitle) {
|
||||
// If from a collection, show collection name
|
||||
breadcrumbPath.push({
|
||||
label: productView.collectionTitle,
|
||||
onClick: productView.onBack || null,
|
||||
});
|
||||
} else {
|
||||
// Otherwise show category
|
||||
const categoryKey = MARKETPLACE_TYPE_TO_KEY[productView.type];
|
||||
if (categoryKey) {
|
||||
breadcrumbPath.push({
|
||||
@@ -109,24 +174,23 @@ function ModalTopBar({
|
||||
});
|
||||
}
|
||||
}
|
||||
// Add product name as final breadcrumb
|
||||
breadcrumbPath.push({
|
||||
label: productView.name,
|
||||
onClick: null, // Current item - not clickable
|
||||
onClick: null,
|
||||
});
|
||||
}
|
||||
} else if (currentSection) {
|
||||
// Show: Tab > Section or Tab > Section > Sub-Section
|
||||
breadcrumbPath.push({
|
||||
label: currentSection,
|
||||
onClick: currentSubSection ? () => onSubSectionChange(null) : null, // Clickable if sub-section is active
|
||||
onClick: currentSubSection
|
||||
? () => onSubSectionChange(null, currentSectionName)
|
||||
: null,
|
||||
});
|
||||
|
||||
// Add sub-section if present
|
||||
if (currentSubSection) {
|
||||
breadcrumbPath.push({
|
||||
label: getSubSectionLabel(currentSubSection, currentSectionName),
|
||||
onClick: null, // Current sub-section - not clickable
|
||||
onClick: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,35 +200,30 @@ function ModalTopBar({
|
||||
<div className="modalTopBar">
|
||||
<div className="topBarLeft">
|
||||
<div className="navigationButtons">
|
||||
<Tooltip title="Back" key="backTooltip">
|
||||
<Tooltip title={t('common.navigation.back')} key="backTooltip">
|
||||
<button
|
||||
className="navButton"
|
||||
onClick={onBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Navigate back"
|
||||
aria-label={t('common.navigation.navigate_back')}
|
||||
>
|
||||
<MdArrowBack />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Forward" key="forwardTooltip">
|
||||
<Tooltip title={t('common.navigation.forward')} key="forwardTooltip">
|
||||
<button
|
||||
className="navButton"
|
||||
onClick={onForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Navigate forward"
|
||||
aria-label={t('common.navigation.navigate_forward')}
|
||||
>
|
||||
<MdArrowForward />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<img
|
||||
src={mueAboutIcon}
|
||||
alt="Mue"
|
||||
className="topBarLogo"
|
||||
draggable={false}
|
||||
/>
|
||||
<img src={mueAboutIcon} alt="Mue" className="topBarLogo" draggable={false} />
|
||||
{breadcrumbPath.length > 0 && (
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb navigation">
|
||||
<nav className="breadcrumbs" aria-label={t('common.navigation.breadcrumb_navigation')}>
|
||||
{breadcrumbPath.map((item, index) => {
|
||||
const isLast = index === breadcrumbPath.length - 1;
|
||||
const isClickable = item.onClick !== null;
|
||||
@@ -183,7 +242,7 @@ function ModalTopBar({
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Navigate to ${item.label}`}
|
||||
aria-label={t('common.navigation.navigate_to', { item: item.label })}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -195,7 +254,9 @@ function ModalTopBar({
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!isLast && <MdChevronRight className="breadcrumb-separator" aria-hidden="true" />}
|
||||
{!isLast && (
|
||||
<MdChevronRight className="breadcrumb-separator" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
@@ -204,16 +265,22 @@ function ModalTopBar({
|
||||
</div>
|
||||
<div className="topBarRight">
|
||||
<div className="topBarNavigation">
|
||||
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => (
|
||||
<Button
|
||||
key={tab}
|
||||
type="navigation"
|
||||
onClick={() => onTabChange(tab)}
|
||||
active={currentTab === tab}
|
||||
icon={<Icon />}
|
||||
label={t(messageKey)}
|
||||
/>
|
||||
))}
|
||||
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => {
|
||||
const badgeValue =
|
||||
tab === TAB_TYPES.LIBRARY && installedCount > 0 ? installedCount : undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
type="navigation"
|
||||
onClick={() => onTabChange(tab)}
|
||||
active={currentTab === tab}
|
||||
icon={<Icon />}
|
||||
label={t(messageKey)}
|
||||
badge={badgeValue}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tooltip title={t('modals.welcome.buttons.close')} key="closeTooltip">
|
||||
<span className="closeModal" onClick={onClose}>
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import variables from 'config/variables';
|
||||
import { useT } from 'contexts';
|
||||
import { MdRefresh, MdClose } from 'react-icons/md';
|
||||
|
||||
const ReminderInfo = ({ isVisible, onHide }) => {
|
||||
const t = useT();
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="reminder-info">
|
||||
<div className="shareHeader">
|
||||
<span className="title">{variables.getMessage('modals.main.settings.reminder.title')}</span>
|
||||
<span className="closeModal" onClick={onHide}>
|
||||
<MdClose />
|
||||
</span>
|
||||
</div>
|
||||
<span className="subtitle">
|
||||
{variables.getMessage('modals.main.settings.reminder.message')}
|
||||
</span>
|
||||
<span className="title">{t('modals.main.settings.reminder.title')}</span>
|
||||
<span className="subtitle">{t('modals.main.settings.reminder.message')}</span>
|
||||
<button onClick={() => window.location.reload()}>
|
||||
<MdRefresh />
|
||||
{variables.getMessage('modals.main.error_boundary.refresh')}
|
||||
{t('modals.main.error_boundary.refresh')}
|
||||
</button>
|
||||
<span className="closeModal" onClick={onHide}>
|
||||
<MdClose />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
import { TAB_TYPES } from '../constants/tabConfig';
|
||||
|
||||
// Tab-specific configurations with exact divider positions
|
||||
const TAB_CONFIGS = {
|
||||
[TAB_TYPES.SETTINGS]: {
|
||||
itemCount: 16, // Excluding experimental
|
||||
dividerPositions: [10, 12], // After Weather, Language
|
||||
textWidths: [80, 100, 70, 90, 85, 75, 80, 95, 90, 75, 85, 90, 85, 80, 70, 95], // Fixed widths in pixels
|
||||
itemCount: 16,
|
||||
dividerPositions: [10, 12],
|
||||
textWidths: [80, 100, 70, 90, 85, 75, 80, 95, 90, 75, 85, 90, 85, 80, 70, 95],
|
||||
showSearch: true,
|
||||
},
|
||||
[TAB_TYPES.DISCOVER]: {
|
||||
itemCount: 5,
|
||||
dividerPositions: [0], // After "All"
|
||||
textWidths: [60, 95, 95, 110, 90], // Fixed widths
|
||||
dividerPositions: [0],
|
||||
textWidths: [60, 95, 95, 110, 90],
|
||||
showSearch: false,
|
||||
},
|
||||
[TAB_TYPES.LIBRARY]: {
|
||||
itemCount: 0, // Library doesn't show sidebar
|
||||
itemCount: 0,
|
||||
dividerPositions: [],
|
||||
textWidths: [],
|
||||
showSearch: false,
|
||||
},
|
||||
};
|
||||
|
||||
const SidebarSkeleton = ({ currentTab = TAB_TYPES.SETTINGS }) => {
|
||||
const config = TAB_CONFIGS[currentTab] || TAB_CONFIGS[TAB_TYPES.SETTINGS];
|
||||
|
||||
// Library tab doesn't show sidebar
|
||||
if (config.itemCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sidebarSkeleton">
|
||||
{/* Header with toggle button and optional search */}
|
||||
<div className="skeletonHeader">
|
||||
<div className="skeletonToggle pulse" />
|
||||
{config.showSearch && <div className="skeletonSearch pulse" />}
|
||||
</div>
|
||||
|
||||
{Array.from({ length: config.itemCount }).map((_, index) => {
|
||||
const hasDivider = config.dividerPositions.includes(index);
|
||||
const textWidth = config.textWidths[index] || 80;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FiSidebar } from 'react-icons/fi';
|
||||
import { Tooltip } from 'components/Elements';
|
||||
import { useT } from 'contexts/TranslationContext';
|
||||
|
||||
function SidebarToggle({ isCollapsed, onToggle }) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={isCollapsed ? t('modals.main.sidebar.expand') : t('modals.main.sidebar.collapse')}
|
||||
placement="right"
|
||||
>
|
||||
<button
|
||||
className="sidebarToggleButton"
|
||||
onClick={onToggle}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<FiSidebar />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarToggle;
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as ModalLoader } from './ModalLoader';
|
||||
export { default as ModalNavbar } from './ModalNavbar';
|
||||
export { default as ReminderInfo } from './ReminderInfo';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import variables from 'config/variables';
|
||||
import {
|
||||
MdSettings,
|
||||
MdWidgets,
|
||||
MdShoppingBasket,
|
||||
MdTune,
|
||||
MdBookmarks,
|
||||
MdExplore,
|
||||
@@ -28,14 +26,12 @@ import {
|
||||
MdCollectionsBookmark,
|
||||
} from 'react-icons/md';
|
||||
|
||||
// Tab type constants
|
||||
export const TAB_TYPES = {
|
||||
SETTINGS: 'settings',
|
||||
LIBRARY: 'library',
|
||||
DISCOVER: 'discover',
|
||||
};
|
||||
|
||||
// Icon component mapping - using component references instead of elements
|
||||
export const ICON_COMPONENTS = {
|
||||
SETTINGS: MdTune,
|
||||
LIBRARY: MdBookmarks,
|
||||
@@ -63,9 +59,8 @@ export const ICON_COMPONENTS = {
|
||||
COLLECTIONS: MdCollectionsBookmark,
|
||||
};
|
||||
|
||||
// Message keys for icon mapping
|
||||
export const MESSAGE_KEYS = {
|
||||
OVERVIEW: 'modals.main.marketplace.product.overview',
|
||||
OVERVIEW: 'modals.main.settings.sections.order.title',
|
||||
SETTINGS: 'modals.main.navbar.settings',
|
||||
LIBRARY: 'modals.main.navbar.library',
|
||||
DISCOVER: 'modals.main.navbar.discover',
|
||||
@@ -95,8 +90,6 @@ export const MESSAGE_KEYS = {
|
||||
COLLECTIONS: 'modals.main.marketplace.collections',
|
||||
};
|
||||
|
||||
// Helper to get icon component by translated label
|
||||
// This function builds a map at runtime using variables.getMessage
|
||||
export const getIconComponent = (label, variables) => {
|
||||
const iconMap = {
|
||||
[variables.getMessage(MESSAGE_KEYS.OVERVIEW)]: ICON_COMPONENTS.OVERVIEW,
|
||||
@@ -132,7 +125,6 @@ export const getIconComponent = (label, variables) => {
|
||||
return iconMap[label];
|
||||
};
|
||||
|
||||
// Navbar configuration
|
||||
export const NAVBAR_BUTTONS = [
|
||||
{
|
||||
tab: TAB_TYPES.SETTINGS,
|
||||
@@ -151,7 +143,6 @@ export const NAVBAR_BUTTONS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Labels that should have dividers after them
|
||||
export const DIVIDER_LABELS = [
|
||||
'modals.main.settings.sections.weather.title',
|
||||
'modals.main.settings.sections.language.title',
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
@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 *;
|
||||
@use 'settings/main' as *;
|
||||
@use 'marketplace/main' as *;
|
||||
// Fixed: Added sass:map module
|
||||
|
||||
/* Fixed: Added sass:map module */
|
||||
|
||||
.Overlay {
|
||||
position: fixed;
|
||||
@@ -18,6 +20,16 @@
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&.ReactModal__Overlay--after-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.ReactModal__Overlay--before-close {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.Modal {
|
||||
@@ -26,14 +38,15 @@
|
||||
}
|
||||
|
||||
box-shadow: 0 0 20px rgb(0 0 0 / 30%);
|
||||
opacity: 1;
|
||||
opacity: 0;
|
||||
z-index: -2;
|
||||
transition-timing-function: ease-in;
|
||||
border-radius: map.get($modal, 'border-radius');
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
transform: scale(0);
|
||||
transition: all 0.3s cubic-bezier(0.47, 1.64, 0.41, 0.8);
|
||||
transform: scale(0.95);
|
||||
transition: opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
@@ -47,7 +60,7 @@
|
||||
.closePositioning {
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
right: 3rem;
|
||||
inset-inline-end: 3rem;
|
||||
}
|
||||
|
||||
.closeModal {
|
||||
@@ -57,11 +70,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);
|
||||
@@ -82,14 +102,13 @@
|
||||
|
||||
.ReactModal__Content--before-close {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
#modal {
|
||||
height: 80vh;
|
||||
width: clamp(60vw, 1400px, 90vw);
|
||||
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
@@ -206,7 +225,7 @@ h5 {
|
||||
}
|
||||
|
||||
.languageSettings {
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 50px;
|
||||
|
||||
.MuiFormGroup-root {
|
||||
gap: 5px;
|
||||
@@ -261,7 +280,7 @@ h5 {
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($modal-border);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
@@ -286,6 +305,7 @@ h5 {
|
||||
|
||||
padding: 15px;
|
||||
border-radius: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,27 +324,51 @@ h5 {
|
||||
|
||||
.reminder-info {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
padding: 15px;
|
||||
gap: 15px;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-secondaryColour);
|
||||
border-radius: t($borderRadius);
|
||||
border: 1px solid t($modal-sidebarActive);
|
||||
border-bottom: 1px solid t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
opacity: 0.7;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
@include basicIconButton(5px, 5px, modal);
|
||||
@include basicIconButton(8px 14px, 0.875rem, modal);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
margin: 0 !important;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.closeModal {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +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-gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
@@ -41,7 +23,7 @@
|
||||
background-size: cover;
|
||||
padding: 1.5rem;
|
||||
will-change: transform;
|
||||
transform: translate3d(0, 0, 0); // Force GPU acceleration
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-secondaryColour);
|
||||
@@ -62,6 +44,24 @@
|
||||
width: 60px !important;
|
||||
border-radius: 12px;
|
||||
transition: 0.5s;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
}
|
||||
|
||||
&.item-icon-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-details {
|
||||
@@ -69,6 +69,7 @@
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
@@ -113,6 +114,39 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.item-disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
.card-title,
|
||||
.card-subtitle {
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-uninstall-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(220 50 50 / 90%);
|
||||
}
|
||||
}
|
||||
|
||||
.item-installed-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
@@ -123,8 +157,8 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid rgb(255 255 255 / 40%);
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 30%);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
@@ -135,12 +169,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
.item-sideload-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(100 100 100 / 90%);
|
||||
cursor: help;
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .item-installed-badge {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.item-sideloaded {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.itemPage {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
@@ -164,161 +229,36 @@
|
||||
table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
font-size: 16px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.itemTop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(100 100 100 / 90%);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,7 +274,7 @@
|
||||
.emptyMessage {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
grid-gap: 5px;
|
||||
gap: 5px;
|
||||
padding: 50px;
|
||||
|
||||
.title,
|
||||
@@ -365,7 +305,7 @@
|
||||
flex-flow: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
user-select: none;
|
||||
|
||||
img {
|
||||
@@ -408,36 +348,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;
|
||||
flex-flow: row;
|
||||
@@ -460,147 +370,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;
|
||||
@@ -626,7 +395,7 @@ p.author {
|
||||
margin-bottom: 15px;
|
||||
|
||||
.tooltip {
|
||||
margin-right: 25px;
|
||||
margin-inline-end: 25px;
|
||||
}
|
||||
|
||||
.mainTitle {
|
||||
@@ -658,7 +427,7 @@ p.author {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@include themed {
|
||||
@@ -668,7 +437,7 @@ p.author {
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,8 +455,105 @@ p.author {
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.5);
|
||||
outline: 2px solid rgb(255 255 255 / 50%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.view-toggle-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.view-toggle-btn {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
@include themed {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgb(255 255 255 / 50%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.items-list {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
grid-template-columns: unset !important;
|
||||
gap: 12px !important;
|
||||
|
||||
.item {
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
padding: 1rem 1.5rem !important;
|
||||
gap: 20px !important;
|
||||
|
||||
&:hover {
|
||||
transform: translate3d(5px, 0, 0) !important;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
flex: 1;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-chips {
|
||||
margin-top: 0 !important;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.side {
|
||||
float: right;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
p.description {
|
||||
margin-top: 0;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.moreInfo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 30px;
|
||||
|
||||
.items {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1 0 40% !important;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
|
||||
svg {
|
||||
@include themed {
|
||||
background-image: t($slightGradient);
|
||||
box-shadow: t($boxShadow);
|
||||
}
|
||||
|
||||
font-size: 18px;
|
||||
padding: 7px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
font-size: medium;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
// text-transform: uppercase;
|
||||
font-size: small;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
@include themed {
|
||||
background: t($modal-secondaryColour);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
border-radius: t($borderRadius);
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.subHeader {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
|
||||
.itemWarning {
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
svg {
|
||||
@include themed {
|
||||
background-image: t($slightGradient);
|
||||
box-shadow: t($boxShadow);
|
||||
}
|
||||
|
||||
padding: 7px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebar);
|
||||
border-radius: t($borderRadius);
|
||||
box-shadow: 0 0 0 1px t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: 1 0 40% !important;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1 0 44%;
|
||||
|
||||
svg {
|
||||
@include themed {
|
||||
background-image: t($slightGradient);
|
||||
box-shadow: t($boxShadow);
|
||||
}
|
||||
|
||||
font-size: 18px;
|
||||
padding: 7px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: medium;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: small;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.showMoreItems {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.marketplaceDescription {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 15px;
|
||||
|
||||
.subtitle {
|
||||
-webkit-user-select: text !important;
|
||||
user-select: text !important;
|
||||
}
|
||||
}
|
||||
|
||||
.moreFromCurator {
|
||||
margin-top: 50px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 15px;
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.btn-default {
|
||||
@include modal-button(standard);
|
||||
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.updateCheck {
|
||||
flex-flow: row !important;
|
||||
}
|
||||
@@ -11,6 +22,11 @@
|
||||
margin-top: 0;
|
||||
float: none !important;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -20,6 +36,11 @@
|
||||
margin-top: 0;
|
||||
float: none !important;
|
||||
padding: 0 20px;
|
||||
font-weight: 500;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-navigation {
|
||||
@@ -27,7 +48,9 @@
|
||||
|
||||
padding: 10px 20px;
|
||||
border-radius: 12px !important;
|
||||
transition: all 0.2s ease;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
position: relative;
|
||||
|
||||
@include themed {
|
||||
@@ -48,18 +71,22 @@
|
||||
|
||||
&:hover {
|
||||
@include themed {
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
background: rgb(0 0 0 / 4%) !important;
|
||||
}
|
||||
|
||||
.light & {
|
||||
background: rgba(0, 0, 0, 0.04) !important;
|
||||
background: rgb(0 0 0 / 4%) !important;
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
background: rgb(255 255 255 / 6%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
span,
|
||||
svg {
|
||||
font-size: 1.1em !important;
|
||||
@@ -72,6 +99,51 @@
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-badge {
|
||||
margin-inline-start: 3px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75em !important;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
|
||||
@include themed {
|
||||
background-color: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
.light & {
|
||||
background-color: rgb(0 0 0 / 8%);
|
||||
color: rgb(0 0 0 / 80%);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background-color: rgb(255 255 255 / 12%);
|
||||
color: rgb(255 255 255 / 90%);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-navigation-active .btn-badge {
|
||||
@include themed {
|
||||
background-color: rgb(0 0 0 / 8%);
|
||||
color: rgb(0 0 0 / 80%);
|
||||
}
|
||||
|
||||
.light & {
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
color: rgb(0 0 0 / 85%);
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
color: rgb(255 255 255 / 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* safari fix */
|
||||
@@ -83,17 +155,17 @@
|
||||
|
||||
.btn-navigation-active {
|
||||
@include themed {
|
||||
background: rgba(0, 0, 0, 0.06) !important;
|
||||
background: rgb(0 0 0 / 6%) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.light & {
|
||||
background: rgba(0, 0, 0, 0.06) !important;
|
||||
background: rgb(0 0 0 / 6%) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.dark & {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
background: rgb(255 255 255 / 10%) !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +184,11 @@
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
transition: 0.5s;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.1s ease,
|
||||
border 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@@ -125,13 +201,15 @@
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($color);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 1px t($color);
|
||||
box-shadow: 0 0 0 2px t($color);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +217,7 @@
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +241,11 @@ a.btn-collection {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border-radius: 8px !important;
|
||||
padding: 0 !important;
|
||||
|
||||
@include modal-button(standard);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +1,79 @@
|
||||
@use 'scss/variables' as *;
|
||||
|
||||
.modalTabContent {
|
||||
width: 100% !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
/* button {
|
||||
@include modal-button(standard);
|
||||
} */
|
||||
|
||||
padding: 1rem 2rem 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 2rem;
|
||||
|
||||
@include themed {
|
||||
padding: 1rem 2rem 5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: t($modal-background);
|
||||
|
||||
margin: 0;
|
||||
border-radius: t($borderRadius);
|
||||
}
|
||||
|
||||
@extend %tabText;
|
||||
@extend %tabText;
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
background: rgb(196 196 196 / 74%);
|
||||
outline: none;
|
||||
hr {
|
||||
width: 100%;
|
||||
background: rgb(196 196 196 / 74%);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: 0.4s ease-in-out;
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
|
||||
@include themed {
|
||||
border-bottom: 1px solid t($modal-border);
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
&.settingsNoBorder {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: 0.4s ease-in-out;
|
||||
flex-flow: column;
|
||||
max-width: 50%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* border-top: 1px solid #ccc; */
|
||||
border-bottom: 1px solid #676767;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
.action {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: flex-end;
|
||||
width: 300px;
|
||||
gap: 10px;
|
||||
|
||||
&.settingsNoBorder {
|
||||
border-bottom: none;
|
||||
button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
.link {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: flex-end;
|
||||
width: 300px;
|
||||
|
||||
button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
flex-flow: row;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +82,7 @@
|
||||
.resetDataButtonsLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 20px;
|
||||
gap: 20px;
|
||||
|
||||
:nth-child(1) {
|
||||
grid-column: span 2;
|
||||
@@ -213,6 +218,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);
|
||||
@@ -222,7 +251,13 @@ table {
|
||||
}
|
||||
|
||||
.messageAction {
|
||||
float: right;
|
||||
[dir='ltr'] & {
|
||||
float: right;
|
||||
}
|
||||
|
||||
[dir='rtl'] & {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +281,7 @@ table {
|
||||
|
||||
hr {
|
||||
height: 100%;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
|
||||
@include themed {
|
||||
border-color: t($modal-secondaryColour);
|
||||
@@ -261,10 +296,10 @@ table {
|
||||
transition: 0.5s;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding-left: 10px;
|
||||
padding-right: 5px;
|
||||
padding-inline: 10px 5px;
|
||||
|
||||
input[type='tel'] {
|
||||
input[type='tel'],
|
||||
input[type='number'] {
|
||||
background: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
@@ -4,46 +4,87 @@
|
||||
@include themed {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
// padding: 1rem 1.5rem 4rem 1.5rem;
|
||||
padding: 0.5rem 0 0 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: t($modal-sidebar);
|
||||
border-radius: 12px 0 0 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-start-start-radius: 12px;
|
||||
border-end-start-radius: 12px;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
overflow: hidden auto;
|
||||
height: 100%;
|
||||
min-width: 250px;
|
||||
flex-shrink: 0;
|
||||
transition: min-width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.sidebarHeader {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.search-input-container {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
|
||||
.search-input-field {
|
||||
height: 38px;
|
||||
font-size: 14px;
|
||||
padding-inline: 42px 14px;
|
||||
padding-block: 0;
|
||||
border-radius: 10px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
inset-inline-start: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
color: t($subColor);
|
||||
font-size: 17px;
|
||||
font-size: 20px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: #ccc;
|
||||
margin: 0 1.75rem;
|
||||
background: t($modal-sidebarActive);
|
||||
margin: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
button:not(.sidebarToggleButton) {
|
||||
color: t($color);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.2rem;
|
||||
padding: 0.5rem;
|
||||
transition: 0.5s;
|
||||
gap: 12px;
|
||||
margin: 0.15rem 0.25rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: none;
|
||||
min-width: calc(100% - 1.2em);
|
||||
text-align: left;
|
||||
width: calc(100% - 0.5rem);
|
||||
text-align: start;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 1rem;
|
||||
@@ -51,21 +92,145 @@
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
|
||||
svg {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 0.5px t($color);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 0.5px t($color);
|
||||
box-shadow: 0 0 0 2px t($color);
|
||||
}
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
opacity 0.25s ease,
|
||||
max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarEmptyState {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: t($color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tab-list-active {
|
||||
background: t($modal-sidebarActive);
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: t($color);
|
||||
|
||||
[dir='ltr'] & {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
[dir='rtl'] & {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
min-width: 64px;
|
||||
padding: 0.75rem 0.25rem;
|
||||
|
||||
.sidebarHeader {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button:not(.sidebarToggleButton) {
|
||||
justify-content: center;
|
||||
padding: 0.65rem;
|
||||
gap: 0;
|
||||
|
||||
span {
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-list-active::before {
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 60%;
|
||||
height: 3px;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
hr {
|
||||
opacity: 0;
|
||||
margin: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebarToggleButton {
|
||||
@include themed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: t($subColor);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
font-size: 18px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
|
||||
svg {
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: t($modal-sidebarActive);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: t($modal-sidebarActive);
|
||||
box-shadow: 0 0 0 2px t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,10 +240,39 @@
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
// Sidebar skeleton loader
|
||||
.sidebarSkeleton {
|
||||
padding: 0.5rem 0.2rem;
|
||||
|
||||
.skeletonHeader {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.skeletonToggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonSearch {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
|
||||
@include themed {
|
||||
background: t($modal-sidebarActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeletonItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -90,8 +284,7 @@
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 6px;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
margin-inline: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include themed {
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.5rem 1.5rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
// width: 100%;
|
||||
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
|
||||
@include themed {
|
||||
@@ -74,6 +75,7 @@
|
||||
.breadcrumb-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -81,6 +83,7 @@
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
@@ -98,6 +101,7 @@
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
}
|
||||
|
||||
opacity: 0.5;
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0.25rem;
|
||||
@@ -107,6 +111,7 @@
|
||||
@include themed {
|
||||
color: t($color);
|
||||
}
|
||||
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,48 +6,6 @@
|
||||
@use 'modules/tabs/stats' as *;
|
||||
|
||||
input {
|
||||
/* colour picker */
|
||||
&[type='color'] {
|
||||
border-radius: 100%;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
border: none;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
vertical-align: middle;
|
||||
background: none;
|
||||
|
||||
@include themed {
|
||||
border: t($modal-sidebarActive) 1px solid;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* firefox fixes for colour picker (using "," didn't work) */
|
||||
&[type='color']::-moz-color-swatch {
|
||||
border-radius: 100%;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
border: none;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
vertical-align: middle;
|
||||
background: none;
|
||||
|
||||
&::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* date picker */
|
||||
&[type='date'] {
|
||||
width: 260px;
|
||||
@@ -92,13 +50,20 @@ h4 {
|
||||
}
|
||||
|
||||
.imagesTopBar {
|
||||
padding-top: 25px;
|
||||
position: sticky;
|
||||
top: -20px;
|
||||
z-index: 90;
|
||||
padding: 25px 0 15px;
|
||||
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 +86,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-inline-start: 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-inline-start: 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 {
|
||||
@@ -145,7 +231,7 @@ h4 {
|
||||
transition: 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
// Warning banner (used in Search settings and potentially others)
|
||||
/* Warning banner (used in Search settings and potentially others) */
|
||||
.itemWarning {
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
|
||||
@@ -18,7 +18,7 @@ legend {
|
||||
}
|
||||
|
||||
.MuiFormControlLabel-labelPlacementStart {
|
||||
margin-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.MuiSwitch-colorPrimary.Mui-checked + .MuiSwitch-track {
|
||||
@@ -152,11 +152,10 @@ legend,
|
||||
.settingsRow {
|
||||
.MuiFormControlLabel-root {
|
||||
flex-direction: row-reverse;
|
||||
margin-right: 0;
|
||||
margin-inline: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.MuiFormControlLabel-root {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
padding: 20px;
|
||||
grid-gap: 20px;
|
||||
gap: 20px;
|
||||
|
||||
@include themed {
|
||||
div {
|
||||
@@ -95,10 +95,413 @@
|
||||
}
|
||||
}
|
||||
|
||||
.images-grid {
|
||||
display: grid;
|
||||
padding: 1px;
|
||||
|
||||
&.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: rgb(0 0 0 / 60%);
|
||||
backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 30%);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
&: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: rgb(0 0 0 / 60%);
|
||||
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: rgb(0 0 0 / 80%);
|
||||
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: rgb(255 71 87 / 90%);
|
||||
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 rgb(0 0 0 / 30%);
|
||||
|
||||
svg {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 71 87);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .delete-button,
|
||||
.image-checkbox:has(input:checked) ~ * .delete-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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 rgb(255 92 37 / 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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));
|
||||
grid-gap: 30px;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.tabPreview {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.statGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-gap: 10px;
|
||||
gap: 10px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
@@ -43,7 +43,7 @@
|
||||
.achievementsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-gap: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.achievement {
|
||||
@@ -134,7 +134,6 @@
|
||||
button {
|
||||
margin-bottom: 15px;
|
||||
flex-flow: row !important;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { memo } from 'react';
|
||||
import { useT } from 'contexts';
|
||||
import variables from 'config/variables';
|
||||
import { MdClose, MdRestartAlt } from 'react-icons/md';
|
||||
import { setDefaultSettings } from 'utils/settings';
|
||||
import { Tooltip, Button } from 'components/Elements';
|
||||
|
||||
function ResetModal({ modalClose }) {
|
||||
const t = useT();
|
||||
const reset = () => {
|
||||
variables.stats.postEvent('setting', 'Reset');
|
||||
setDefaultSettings('reset');
|
||||
@@ -15,34 +17,32 @@ function ResetModal({ modalClose }) {
|
||||
<div className="smallModal">
|
||||
<div className="shareHeader">
|
||||
<span className="title">
|
||||
{variables.getMessage('modals.main.settings.sections.advanced.reset_modal.title')}
|
||||
{t('modals.main.settings.sections.advanced.reset_modal.title')}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={variables.getMessage('modals.main.settings.sections.advanced.reset_modal.cancel')}
|
||||
>
|
||||
<Tooltip title={t('modals.main.settings.sections.advanced.reset_modal.cancel')}>
|
||||
<div className="close" onClick={modalClose}>
|
||||
<MdClose />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="title">
|
||||
{variables.getMessage('modals.main.settings.sections.advanced.reset_modal.question')}
|
||||
{t('modals.main.settings.sections.advanced.reset_modal.question')}
|
||||
</span>
|
||||
<span className="subtitle">
|
||||
{variables.getMessage('modals.main.settings.sections.advanced.reset_modal.information')}
|
||||
{t('modals.main.settings.sections.advanced.reset_modal.information')}
|
||||
</span>
|
||||
<div className="resetFooter">
|
||||
<Button
|
||||
type="secondary"
|
||||
onClick={modalClose}
|
||||
icon={<MdClose />}
|
||||
label={variables.getMessage('modals.main.settings.sections.advanced.reset_modal.cancel')}
|
||||
label={t('modals.main.settings.sections.advanced.reset_modal.cancel')}
|
||||
/>
|
||||
<Button
|
||||
type="settings"
|
||||
onClick={() => reset()}
|
||||
icon={<MdRestartAlt />}
|
||||
label={variables.getMessage('modals.main.settings.buttons.reset')}
|
||||
label={t('modals.main.settings.buttons.reset')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import variables from 'config/variables';
|
||||
import { useT } from 'contexts';
|
||||
import { MdClose, MdEmail, MdContentCopy } from 'react-icons/md';
|
||||
import { FaFacebookF } from 'react-icons/fa';
|
||||
import { AiFillWechat } from 'react-icons/ai';
|
||||
@@ -12,37 +12,34 @@ import { Button } from 'components/Elements';
|
||||
import './sharemodal.scss';
|
||||
|
||||
function ShareModal({ modalClose, data }) {
|
||||
const t = useT();
|
||||
if (data.startsWith('https://cdn.')) {
|
||||
data = {
|
||||
url: data,
|
||||
name: 'this image',
|
||||
name: t('modals.share.item_type.image'),
|
||||
};
|
||||
} else if (data.startsWith('"')) {
|
||||
data = {
|
||||
url: data,
|
||||
name: 'this quote',
|
||||
name: t('modals.share.item_type.quote'),
|
||||
};
|
||||
} else {
|
||||
data = {
|
||||
url: data,
|
||||
name: 'this marketplace item',
|
||||
name: t('modals.share.item_type.marketplace_item'),
|
||||
};
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(data.url);
|
||||
toast(
|
||||
data.startsWith('"')
|
||||
? variables.getMessage('toasts.quote')
|
||||
: variables.getMessage('toasts.link_copied'),
|
||||
);
|
||||
toast(data.startsWith('"') ? t('toasts.quote') : t('toasts.link_copied'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="smallModal">
|
||||
<div className="shareHeader">
|
||||
<span className="title">{variables.getMessage('widgets.quote.share')}</span>
|
||||
<Tooltip title={variables.getMessage('modals.welcome.buttons.close')}>
|
||||
<span className="title">{t('widgets.quote.share')}</span>
|
||||
<Tooltip title={t('modals.welcome.buttons.close')}>
|
||||
<div className="close" onClick={modalClose}>
|
||||
<MdClose />
|
||||
</div>
|
||||
@@ -53,13 +50,13 @@ function ShareModal({ modalClose, data }) {
|
||||
onClick={() =>
|
||||
window
|
||||
.open(
|
||||
`https://x.com/intent/tweet?text=Check out ${data.name} on @getmue: ${data.url}`,
|
||||
`https://x.com/intent/tweet?text=${t('modals.share.twitter_message', { name: data.name })}: ${data.url}`,
|
||||
'_blank',
|
||||
)
|
||||
.focus()
|
||||
}
|
||||
icon={<SiX />}
|
||||
tooltipTitle="X (Twitter)"
|
||||
tooltipTitle={t('modals.share.social.twitter')}
|
||||
type="icon"
|
||||
/>
|
||||
<Button
|
||||
@@ -69,23 +66,20 @@ function ShareModal({ modalClose, data }) {
|
||||
.focus()
|
||||
}
|
||||
icon={<FaFacebookF />}
|
||||
tooltipTitle="Facebook"
|
||||
tooltipTitle={t('modals.share.social.facebook')}
|
||||
type="icon"
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window
|
||||
.open(
|
||||
'mailto:email@example.com?subject=Check%20out%20this%20%on%20%Mue!&body=' +
|
||||
data.name +
|
||||
'on Mue: ' +
|
||||
data.url,
|
||||
`mailto:email@example.com?subject=${encodeURIComponent(t('modals.share.email_subject'))}&body=${encodeURIComponent(t('modals.share.email_body', { name: data.name, url: data.url }))}`,
|
||||
'_blank',
|
||||
)
|
||||
.focus()
|
||||
}
|
||||
icon={<MdEmail />}
|
||||
tooltipTitle="Email"
|
||||
tooltipTitle={t('modals.share.social.email')}
|
||||
type="icon"
|
||||
/>
|
||||
<Button
|
||||
@@ -98,7 +92,7 @@ function ShareModal({ modalClose, data }) {
|
||||
.focus()
|
||||
}
|
||||
icon={<AiFillWechat />}
|
||||
tooltipTitle="WeChat"
|
||||
tooltipTitle={t('modals.share.social.wechat')}
|
||||
type="icon"
|
||||
/>
|
||||
<Button
|
||||
@@ -108,7 +102,7 @@ function ShareModal({ modalClose, data }) {
|
||||
.focus()
|
||||
}
|
||||
icon={<SiTencentqq />}
|
||||
tooltipTitle="Tencent QQ"
|
||||
tooltipTitle={t('modals.share.social.qq')}
|
||||
type="icon"
|
||||
/>
|
||||
</div>
|
||||
@@ -117,7 +111,7 @@ function ShareModal({ modalClose, data }) {
|
||||
<Button
|
||||
onClick={() => copyLink()}
|
||||
icon={<MdContentCopy />}
|
||||
tooltipTitle={variables.getMessage('modals.share.copy_link')}
|
||||
tooltipTitle={t('modals.share.copy_link')}
|
||||
type="icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
83
src/components/Elements/SmartIcon/SmartIcon.jsx
Normal file
83
src/components/Elements/SmartIcon/SmartIcon.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IconService } from 'utils/quicklinks';
|
||||
import './smarticon.scss';
|
||||
|
||||
export const SmartIcon = ({ item, size = 32, fallbackChain, className = '' }) => {
|
||||
const [currentIconIndex, setCurrentIconIndex] = useState(0);
|
||||
const [iconUrls, setIconUrls] = useState([]);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [showPlaceholder, setShowPlaceholder] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const urls = IconService.getIconUrl(item, fallbackChain);
|
||||
setIconUrls(Array.isArray(urls) ? urls : [urls]);
|
||||
setCurrentIconIndex(0);
|
||||
setHasError(false);
|
||||
setShowPlaceholder(false);
|
||||
}, [item, fallbackChain]);
|
||||
|
||||
const handleImageError = () => {
|
||||
if (currentIconIndex < iconUrls.length - 1) {
|
||||
setCurrentIconIndex((prev) => prev + 1);
|
||||
setHasError(false);
|
||||
} else {
|
||||
setHasError(true);
|
||||
setShowPlaceholder(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (item.iconType === 'emoji' && item.icon) {
|
||||
return (
|
||||
<div className={`smart-icon emoji ${className}`} style={{ fontSize: size }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.iconType === 'letter' || showPlaceholder) {
|
||||
const avatar = IconService.generateLetterAvatar(item.name);
|
||||
return (
|
||||
<div
|
||||
className={`smart-icon letter-avatar ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: avatar.backgroundColor,
|
||||
fontSize: size * 0.5,
|
||||
}}
|
||||
>
|
||||
{avatar.letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentUrl = iconUrls[currentIconIndex];
|
||||
if (!currentUrl) {
|
||||
const avatar = IconService.generateLetterAvatar(item.name);
|
||||
return (
|
||||
<div
|
||||
className={`smart-icon letter-avatar ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: avatar.backgroundColor,
|
||||
fontSize: size * 0.5,
|
||||
}}
|
||||
>
|
||||
{avatar.letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={item.name}
|
||||
className={`smart-icon ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
onError={handleImageError}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
};
|
||||
1
src/components/Elements/SmartIcon/index.js
Normal file
1
src/components/Elements/SmartIcon/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { SmartIcon } from './SmartIcon';
|
||||
26
src/components/Elements/SmartIcon/smarticon.scss
Normal file
26
src/components/Elements/SmartIcon/smarticon.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
.smart-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.emoji {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&.letter-avatar {
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, memo, useRef } from 'react';
|
||||
import { useState, memo, useRef, useId } from 'react';
|
||||
import { useFloating, flip, offset, shift } from '@floating-ui/react-dom';
|
||||
import './tooltip.scss';
|
||||
|
||||
function Tooltip({ children, title, style, placement, subtitle }) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [reference, setReference] = useState(null);
|
||||
const tooltipId = useRef(`tooltip-${Math.random()}`);
|
||||
const tooltipId = useId();
|
||||
const closeTimeout = useRef(null);
|
||||
|
||||
const {
|
||||
@@ -23,23 +23,23 @@ function Tooltip({ children, title, style, placement, subtitle }) {
|
||||
},
|
||||
});
|
||||
|
||||
const { setFloating } = refs;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// Clear any pending close timeout if mouse re-enters during exit
|
||||
if (closeTimeout.current) {
|
||||
clearTimeout(closeTimeout.current);
|
||||
closeTimeout.current = null;
|
||||
}
|
||||
setIsClosing(false);
|
||||
setClosing(false);
|
||||
setShowTooltip(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsClosing(true);
|
||||
// Wait for exit animation to complete before unmounting
|
||||
setClosing(true);
|
||||
closeTimeout.current = setTimeout(() => {
|
||||
setShowTooltip(false);
|
||||
setIsClosing(false);
|
||||
}, 200); // Match exit animation duration
|
||||
setClosing(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -47,22 +47,25 @@ function Tooltip({ children, title, style, placement, subtitle }) {
|
||||
clearTimeout(closeTimeout.current);
|
||||
closeTimeout.current = null;
|
||||
}
|
||||
setIsClosing(false);
|
||||
setClosing(false);
|
||||
setShowTooltip(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsClosing(true);
|
||||
setClosing(true);
|
||||
closeTimeout.current = setTimeout(() => {
|
||||
setShowTooltip(false);
|
||||
setIsClosing(false);
|
||||
setClosing(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Determine the data-status attribute value
|
||||
const getStatus = () => {
|
||||
if (!showTooltip && !isClosing) return 'initial';
|
||||
if (isClosing) return 'close';
|
||||
if (!showTooltip && !closing) {
|
||||
return 'initial';
|
||||
}
|
||||
if (closing) {
|
||||
return 'close';
|
||||
}
|
||||
return 'open';
|
||||
};
|
||||
|
||||
@@ -76,13 +79,13 @@ function Tooltip({ children, title, style, placement, subtitle }) {
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
ref={setReference}
|
||||
aria-describedby={tooltipId.current}
|
||||
aria-describedby={tooltipId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{(showTooltip || isClosing) && (
|
||||
{(showTooltip || closing) && (
|
||||
<span
|
||||
ref={refs.setFloating}
|
||||
ref={setFloating}
|
||||
style={{
|
||||
position: strategy,
|
||||
top: y ?? '',
|
||||
|
||||
@@ -5,6 +5,54 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@keyframes floatingFromTop {
|
||||
0% {
|
||||
transform: translateY(5px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatingFromBottom {
|
||||
0% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatingFromLeft {
|
||||
0% {
|
||||
transform: translateX(5px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatingFromRight {
|
||||
0% {
|
||||
transform: translateX(-5px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floating {
|
||||
0% {
|
||||
transform: translate(0, -5px);
|
||||
@@ -37,21 +85,36 @@
|
||||
opacity 0.2s ease-out,
|
||||
transform 0.2s ease-out;
|
||||
|
||||
// Initial state (not yet shown)
|
||||
/* Initial state (not yet shown) */
|
||||
&[data-status='initial'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Open state - entrance animation
|
||||
/* Open state - entrance animation */
|
||||
&[data-status='open'] {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
animation-name: floating;
|
||||
animation-duration: 0.3s;
|
||||
animation-timing-function: ease-in;
|
||||
|
||||
&[data-placement^='top'] {
|
||||
animation-name: floatingFromTop;
|
||||
}
|
||||
|
||||
&[data-placement^='bottom'] {
|
||||
animation-name: floatingFromBottom;
|
||||
}
|
||||
|
||||
&[data-placement^='left'] {
|
||||
animation-name: floatingFromLeft;
|
||||
}
|
||||
|
||||
&[data-placement^='right'] {
|
||||
animation-name: floatingFromRight;
|
||||
}
|
||||
}
|
||||
|
||||
// Closing state - exit animation
|
||||
/* Closing state - exit animation */
|
||||
&[data-status='close'] {
|
||||
opacity: 0;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -16,35 +18,44 @@ const Checkbox = memo((props) => {
|
||||
props.onChange(value);
|
||||
}
|
||||
|
||||
variables.stats.postEvent(
|
||||
'setting',
|
||||
`${props.name} ${checked ? 'enabled' : 'disabled'}`,
|
||||
);
|
||||
variables.stats.postEvent('setting', `${props.name} ${value ? 'enabled' : 'disabled'}`);
|
||||
|
||||
if (props.element) {
|
||||
if (!document.querySelector(props.element)) {
|
||||
document.querySelector('.reminder-info').style.display = 'flex';
|
||||
return localStorage.setItem('showReminder', true);
|
||||
localStorage.setItem('showReminder', 'true');
|
||||
EventBus.emit('showReminder');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.emit('refresh', props.category);
|
||||
}, [checked, props]);
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<CheckboxUI
|
||||
name={props.name}
|
||||
color="primary"
|
||||
className="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={props.disabled || false}
|
||||
/>
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if ((e.key === ' ' || e.key === 'Enter') && !props.disabled) {
|
||||
e.preventDefault();
|
||||
handleChange();
|
||||
}
|
||||
label={props.text}
|
||||
/>
|
||||
},
|
||||
[handleChange, props.disabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
119
src/components/Form/Settings/Checkbox/Checkbox.scss
Normal file
119
src/components/Form/Settings/Checkbox/Checkbox.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
@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;
|
||||
font-size: 1rem;
|
||||
|
||||
@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 rgb(255 92 37 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
&.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: 1.125rem;
|
||||
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,60 +1,189 @@
|
||||
import { useState, memo } from 'react';
|
||||
import { useState, memo, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MdExpandMore, MdClose, MdCheck } from 'react-icons/md';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import './ChipSelect.scss';
|
||||
|
||||
function ChipSelect({ label, options, onChange }) {
|
||||
let start = (localStorage.getItem('apiCategories') || '').split(',');
|
||||
function ChipSelect({ label, options, onChange, name }) {
|
||||
const storageKey = name || 'apiCategories';
|
||||
let start = (localStorage.getItem(storageKey) || '').split(',');
|
||||
if (start[0] === '') {
|
||||
start = [];
|
||||
}
|
||||
|
||||
const [optionsSelected, setoptionsSelected] = useState(start);
|
||||
const [optionsSelected, setOptionsSelected] = useState(start);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const containerRef = useRef(null);
|
||||
const controlRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setoptionsSelected(typeof value === 'string' ? value.split(',') : value);
|
||||
localStorage.setItem('apiCategories', value);
|
||||
const closeDropdown = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
setClosing(false);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
// Ignore clicks on color inputs to prevent closing when native color picker opens
|
||||
if (event.target.type === 'color') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Also ignore clicks on color input labels and wrappers
|
||||
const target = event.target;
|
||||
if (target.tagName === 'LABEL' && target.htmlFor) {
|
||||
const associatedInput = document.getElementById(target.htmlFor) || document.querySelector(`input[name="${target.htmlFor}"]`);
|
||||
if (associatedInput && associatedInput.type === 'color') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if clicking within a color input wrapper
|
||||
const colorInputWrapper = target.closest('.colour-picker');
|
||||
if (colorInputWrapper && colorInputWrapper.querySelector('input[type="color"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [closeDropdown]);
|
||||
|
||||
const calculatePosition = useCallback(() => {
|
||||
if (controlRef.current) {
|
||||
const rect = controlRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const estimatedMenuHeight = Math.min(options.length * 44, 250);
|
||||
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap;
|
||||
const spaceAbove = rect.top - gap;
|
||||
|
||||
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
return {
|
||||
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow),
|
||||
flipped: shouldFlipUp,
|
||||
};
|
||||
}
|
||||
return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false };
|
||||
}, [options]);
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
const position = calculatePosition();
|
||||
setMenuPosition(position);
|
||||
setOpen(true);
|
||||
}, [calculatePosition]);
|
||||
|
||||
const handleToggle = (optionName) => {
|
||||
let newSelected;
|
||||
if (optionsSelected.includes(optionName)) {
|
||||
newSelected = optionsSelected.filter((item) => item !== optionName);
|
||||
} else {
|
||||
newSelected = [...optionsSelected, optionName];
|
||||
}
|
||||
|
||||
setOptionsSelected(newSelected);
|
||||
const storageKey = name || 'apiCategories';
|
||||
localStorage.setItem(storageKey, newSelected.join(','));
|
||||
|
||||
// Call parent onChange if provided
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
onChange(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveChip = (e, optionName) => {
|
||||
e.stopPropagation();
|
||||
handleToggle(optionName);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<InputLabel id="chipSelect-label">{label}</InputLabel>
|
||||
<Select
|
||||
labelId="chipSelect-label"
|
||||
id="chipSelect"
|
||||
multiple
|
||||
value={optionsSelected}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-multiple-chip" label={label} />}
|
||||
renderValue={(optionsSelected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{optionsSelected.map((value) => (
|
||||
<Chip key={value} label={value} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<div className="chipSelect" ref={containerRef}>
|
||||
{label && <label className="chipSelect-label">{label}</label>}
|
||||
<div
|
||||
ref={controlRef}
|
||||
className="chipSelect-control"
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option.name} value={option.name}>
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}{' '}
|
||||
{option.count && `(${option.count})`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="chipSelect-value">
|
||||
{optionsSelected.length === 0 ? (
|
||||
<span className="chipSelect-placeholder">Select options...</span>
|
||||
) : (
|
||||
<div className="chipSelect-chips">
|
||||
{optionsSelected.map((value) => (
|
||||
<span key={value} className="chipSelect-chip">
|
||||
{value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
<button
|
||||
type="button"
|
||||
className="chipSelect-chip-remove"
|
||||
onClick={(e) => handleRemoveChip(e, value)}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MdExpandMore className={`chipSelect-arrow ${open ? 'open' : ''}`} />
|
||||
</div>
|
||||
{(open || closing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`chipSelect-dropdown ${closing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
width: `${menuPosition.width}px`,
|
||||
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
>
|
||||
<div className="chipSelect-option-checkbox">
|
||||
{optionsSelected.includes(option.name) && <MdCheck />}
|
||||
</div>
|
||||
<span className="chipSelect-option-label">
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
|
||||
{option.count && ` (${option.count})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user