Compare commits

...

188 Commits
v7.6.1 ... beta

Author SHA1 Message Date
alexsparkes
64538d0170 fix(beta-release): show only commits not yet in main to prevent giant changelogs 2026-04-23 23:15:07 +01:00
alexsparkes
e123e06bdd fix(dropdown): remove duplicate closeDropdown declaration from merge artifact 2026-04-23 23:09:57 +01:00
alexsparkes
facc95e8dd Merge remote-tracking branch 'origin/dev' into beta 2026-04-23 23:05:24 +01:00
alexsparkes
29fd7181c7 feat(workflows): add auto-deploy to Chrome Web Store on release
- submit.yml: rewrite with release:[released] trigger, upgrade to BPP v3
  (no Puppeteer/Chrome setup needed), chrome-only, environment: production gate
- submit-beta.yml: new workflow, release:[prereleased] trigger, submits to
  Chrome trustedTesters channel, environment: beta gate
- both keep workflow_dispatch as manual fallback for re-runs
- hotfix releases automatically trigger submit.yml via the [released] event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:51:32 +01:00
alexsparkes
55a4b31e30 fix(version-bump): adopt PR-triggered workflow from main and fix sed -i and manifest version bugs
- switch from workflow_dispatch to PR-closed trigger (aligns with main's automation approach)
- fix sed -i '' (macOS syntax) → sed -i (GNU/Linux compatible for Ubuntu runners)
- add stable_version output to strip pre-release suffix before writing to manifests
  (Chrome/Firefox/Safari extension manifests reject semver pre-release strings)
- keep token in checkout step for push permissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:07:51 +01:00
alexsparkes
a3ca7ad9e9 fix(workflows): fix IS_PRERELEASE bug, hotfix changelog, dependabot target, back-merge automation, lint gate, and manifest version mismatch
- fix version-bump.yml: IS_PRERELEASE was never defined, breaking iterative beta versioning (7.x.x-beta.1 → 7.x.x-beta.2)
- fix hotfix-release.yml: move changelog generation before merge so git log captures commits correctly
- fix dependabot.yml: was empty; now targets dev branch to keep Dependabot PRs in the normal dev→beta→main flow
- fix automerge.yml: remove continue-on-error from lint step so lint failures block Dependabot auto-merge; pin bun-version to 1.3.1
- fix production-release.yml: add automated back-merge of main→beta and main→dev after each production release
- fix manifest/chrome.json and manifest/firefox.json: version was 7.6.0, mismatched package.json 7.6.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 22:03:19 +01:00
David Ralph
6cb142b1b3 fix(welcome): import assets as ES modules to fix broken images in production builds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:46:23 +01:00
David Ralph
69d91cc6f1 fix(date): preserve zero-padding when using MDY and YMD date formats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:39:54 +01:00
alexsparkes
1ef65fb5d2 fix: resolve QA bugs across settings, reminder banner, and modal layout
- Replace all querySelector('.reminder-info') calls with EventBus.emit('showReminder')
  across 14 files — prevents crashes when the element is not mounted
- Move ReminderInfo from inside the sidebar to the top of the right content column
  so it no longer obscures sidebar navigation
- Fix reminder not showing for non-hot-reload settings (Slider hook violation where
  useT() was called inside a requestAnimationFrame callback)
- Skip reminder for LocationSearch since it already emits a hot-reload refresh event
- Suppress reminder for settings with hot-reload (no page refresh needed)
- Fix modal content overflowing right edge: replace width:100%!important on
  .modalTabContent with flex:1 + min-width:0 + overflow-x:hidden
- Fix ReminderInfo refresh button being too small
- Fix sidebar collapse animation using max-width transition instead of width:0
- Fix PhotoInformation showing 0x0 resolution and Unknown Location before image loads
- Fix Wikipedia disambiguation links for quote authors by returning null from
  getAuthorLink (Wikidata links used when available)
- Bail early in useSuggestedPacks when offline mode is enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 10:19:50 +01:00
David Ralph
b98478ef68 feat(tests): add basic cypress for welcome and widgets 2026-02-08 23:59:23 +00:00
alexsparkes
031d980301 fix: Prevent duplicate installations in install function 2026-02-08 19:47:31 +00:00
alexsparkes
04e5e26141 Update background options and migrations 2026-02-08 19:41:16 +00:00
alexsparkes
6c6542c08b fix: Enhance deep link handling in Discover component and update RouterBridge for collection navigation 2026-02-07 22:56:34 +00:00
alexsparkes
8c4ea4cf6a fix: Update modal sidebar active color for improved visibility 2026-02-07 22:32:12 +00:00
alexsparkes
e938eef740 fix: Refactor event handling in LocationSearch and streamline modal navigation logic in Modals 2026-02-07 22:28:56 +00:00
alexsparkes
8a28a27282 fix: Improve location retrieval logic in WeatherWidget and enhance selection behavior in LocationSearch 2026-02-07 22:25:54 +00:00
alexsparkes
96e51c96cf fix: Enhance photo pack data structure and improve button layout in PhotoInformation component 2026-02-07 22:19:45 +00:00
alexsparkes
8758188521 fix: Prevent main modal from appearing on top of welcome modal during transitions 2026-02-07 22:01:04 +00:00
alexsparkes
ad7963f8f5 fix: Implement fallback to en_GB in translation retrieval for improved language handling 2026-02-07 21:28:55 +00:00
alexsparkes
bce58afa66 fix: Update modal prop names for consistency in CustomSettings component 2026-02-07 21:19:05 +00:00
alexsparkes
ce085b4457 fix: Correct path for achievement locale loaders and improve loader retrieval logic 2026-02-07 21:10:17 +00:00
alexsparkes
2a35b55cff fix: Correct parameter key usage in fetchFromProvider function for settings handling 2026-02-07 20:49:16 +00:00
alexsparkes
864579de84 feat: Enhance ItemSettingsModal with smoother opening and closing transitions 2026-02-07 20:33:48 +00:00
alexsparkes
9e75be2b89 feat: Enhance PhotoInformation component with metadata display and improved styling; update QuoteInfoModal navigation behavior 2026-02-07 19:33:35 +00:00
alexsparkes
59ceb40a32 feat: Update Quote component and styles for improved visibility and user experience 2026-02-07 19:04:22 +00:00
alexsparkes
348485a724 feat: Update warning message in QuoteInfoModal for improved clarity on data sourcing 2026-02-07 14:54:48 +00:00
alexsparkes
4ad799da3b feat: Add QuoteInfoModal for displaying detailed quote information and integrate it with the Quote component 2026-02-07 14:53:35 +00:00
alexsparkes
f891a16350 feat: Enhance AuthorInfo component with link support and add ErrorElement for improved error handling 2026-02-07 13:48:31 +00:00
alexsparkes
2d7e1ad97a feat: Update refresh handlers to include language and other settings for improved state management 2026-02-07 13:23:06 +00:00
alexsparkes
4d56bbb2c1 feat: Refactor install function to streamline settings and photo handling 2026-02-07 12:47:13 +00:00
alexsparkes
3af0b63a17 feat: Move useT hook initialization to ItemCard and Items components 2026-02-07 12:31:22 +00:00
alexsparkes
1b664ba23e feat: Enhance ItemCard button styles and add install counter with event listener 2026-02-07 12:28:06 +00:00
alexsparkes
eb3af777f5 feat: Implement install functionality for suggested packs and enhance state management 2026-02-07 12:19:47 +00:00
alexsparkes
bbca4b9c7b feat: Enhance item toggle functionality to support item ID retrieval 2026-02-07 12:08:02 +00:00
alexsparkes
553d5001f6 feat: Update navigation to item detail pages and enhance item ID handling 2026-02-07 12:04:24 +00:00
alexsparkes
cf3237f337 feat: Migrate to React Router v7 and refactor navigation
- Added react-router for improved routing and navigation.
- Replaced hash-based navigation with React Router's navigate function.
- Introduced RouterBridge to manage deep linking and legacy navigation.
- Updated components to utilize useNavigate and useLocation hooks.
- Refactored modal handling to integrate with React Router.
- Removed updateHash utility to prevent conflicts with React Router's history management.
- Created routes configuration for application structure and lazy loading.
- Ensured backward compatibility with existing deep link data structure.
2026-02-07 11:48:09 +00:00
alexsparkes
9e336b0582 feat: enhance deep linking functionality in MainModal and Discover components 2026-02-07 11:18:57 +00:00
alexsparkes
474b4ae237 feat(marketplace): Add suggested packs feature to marketplace
- Introduced new SuggestedPacks component to display trending quote and photo packs.
- Implemented useSuggestedPacks hook for fetching and caching suggested packs from the marketplace.
- Updated localization files for multiple languages to include new strings for suggested packs.
- Added explore all button to navigate to the marketplace category page.
2026-02-07 11:02:29 +00:00
alexsparkes
5cbcee522b feat: enhance item toggle functionality to refresh API pack cache on state change 2026-02-07 10:25:41 +00:00
alexsparkes
d31bde91cf feat: implement navigation history management in MainModal 2026-02-06 23:34:27 +00:00
alexsparkes
6231a4aa2d feat: update hash on subSection change and enhance breadcrumb navigation handling 2026-02-06 23:14:44 +00:00
alexsparkes
c23bd1a0e8 feat: enhance photo pack attribution handling and improve API integration 2026-02-06 22:43:15 +00:00
alexsparkes
74565fbeb9 feat: implement marketplace handler registration and refactor default packs installation 2026-02-06 22:09:04 +00:00
alexsparkes
62f7e20f8e feat: implement default photo packs installation and update settings handling 2026-02-06 20:43:54 +00:00
alexsparkes
0af00072f1 feat: add font size to dropdown and location search components; improve fallback handling in background loading and achievements translation 2026-02-06 19:14:47 +00:00
alexsparkes
dbd3eb11ae refactor: remove WelcomeLoader component and update Suspense fallback in Modals 2026-02-06 17:33:24 +00:00
alexsparkes
3c53d0322e feat: implement lazy loading for components and add loading states for improved user experience 2026-02-06 17:30:55 +00:00
alexsparkes
dbfe7e52b5 feat: enhance translation handling by adding fallback mechanism and loading translations dynamically 2026-02-06 16:41:02 +00:00
alexsparkes
5532d02603 feat: update font weight to bold for greeting, quote, and clock components; add TypeScript LSP plugin settings 2026-02-06 15:26:34 +00:00
alexsparkes
1c2a43ae7f docs: update CLAUDE.md to provide clearer guidance on project structure and development practices 2026-02-06 12:24:19 +00:00
alexsparkes
9731bc939e feat: implement ColourPicker component and replace color input in settings 2026-02-05 23:16:06 +00:00
alexsparkes
0d25fbca6d feat: add reset button for greeting, quote, and time options to restore default settings 2026-02-05 23:01:12 +00:00
alexsparkes
a0517acd53 feat: integrate EventBus for color change refresh in Greeting, Quote, and Time options 2026-02-05 22:51:47 +00:00
alexsparkes
8dd6cf2655 feat: enhance click handling for color inputs and update event bus cleanup 2026-02-04 21:15:12 +00:00
alexsparkes
7a390b91ca fix(Dropdown): remove redundant useT() call from handleClickOutside 2026-02-04 20:20:23 +00:00
alexsparkes
192f951d41 Add appearance customization options for time, quotes, and greetings; migrate old font settings to new structure 2026-02-04 20:18:27 +00:00
alexsparkes
94edeba10f fix(Radio): update state management for language selection and local storage 2026-02-04 19:52:26 +00:00
alexsparkes
d67cf65a0e Refactor styles to use RGB color notation with alpha transparency and improve RTL support
- Updated various SCSS files to replace RGBA color definitions with RGB notation using alpha transparency (e.g., rgba(255, 255, 255, 0.1) to rgb(255 255 255 / 10%)).
- Enhanced RTL support by adding new mixins for inline padding, margin, positioning, and text alignment.
- Adjusted themed mixins to remove unnecessary parentheses for consistency.
- Cleaned up font-face declarations in index.scss for improved readability.
- Minor adjustments to layout properties in quicklinks and welcome components for better responsiveness.
2026-02-04 19:49:24 +00:00
alexsparkes
1c7fe206af Refactor welcome modal and sections to use translation context
- Replaced variable-based message retrieval with translation context in Welcome.jsx, Final.jsx, ImportSettings.jsx, Intro.jsx, PrivacyOptions.jsx, StyleSelection.jsx, and ThemeSelection.jsx components.
- Updated button texts and section titles to utilize the new translation method for better localization support.
- Cleaned up unused imports and improved code readability by removing unnecessary else statements in deepLinking.js and load.js.
- Ensured all localization files end with a newline for consistency.
2026-02-04 19:33:39 +00:00
alexsparkes
6044b02749 Add localization for pack and photo counts across multiple languages
- Introduced new keys for "pack_count" and "photo_count" with singular and plural forms in various language JSON files.
- Added "get_more" key for consistency in user interface across different languages.
- Removed redundant definitions of "pack_count", "photo_count", and "get_more" from "photo_pack_settings" to streamline localization files.
2026-02-04 19:11:19 +00:00
alexsparkes
26a1da89b5 fix(ItemCard): conditionally render settings button and modal based on item settings 2026-02-04 16:45:07 +00:00
David Ralph
73ba9b5590 fix(translations): add missing strings 2026-02-04 14:25:01 +00:00
David Ralph
4aeda76cdf fix(translations): add missing strings 2026-02-04 14:00:16 +00:00
David Ralph
3a29f8b48f refactor: reduce verbose variables 2026-02-04 13:35:32 +00:00
David Ralph
bb2b457c1c refactor: remove unnecessary comments 2026-02-04 13:30:14 +00:00
alexsparkes
0ec0676308 fix(photoPackAPI): remove unnecessary whitespace for cleaner code 2026-02-03 22:22:30 +00:00
alexsparkes
ce2fec8407 feat(photoPackAPI): refactor photo fetching logic to support multiple providers and deprecate legacy functions 2026-02-03 22:22:26 +00:00
alexsparkes
7ed5984a3d fix(Dockerfile): ensure proper newline at end of file for CMD instruction 2026-02-03 21:51:57 +00:00
alexsparkes
cfb9915a8b Refactor and update various components and styles
- Added shebang to commit-msg.sh for better script execution.
- Updated Dockerfile to use a specific version of the bun image.
- Improved logging format in SafariWebExtensionHandler.swift for better readability.
- Simplified CSS variable for text color in Style.css.
- Refactored ViewController.swift to enhance readability and maintainability.
- Improved conditional checks in ModalTopBar.jsx and Tooltip.jsx for better clarity.
- Updated SCSS files to use comments consistently and removed redundant lines.
- Enhanced error handling and background loading logic in backgroundLoader.js.
- Refactored useBackgroundEvents.js and useBackgroundLoader.js for better readability.
- Cleaned up quicklinks components and utilities for improved code quality.
- Added empty error.scss file for future styling.
- Updated toast.scss and index.scss with consistent comment styles.
- Improved number formatting logic in formatNumber.js for better clarity.
2026-02-03 21:51:53 +00:00
alexsparkes
d68b5c3d50 fix(ItemSettingsModal): simplify input field transition for improved performance 2026-02-03 21:35:32 +00:00
alexsparkes
f0ff173b3b fix(ItemCard): prevent collection display when item is already added 2026-02-03 21:33:58 +00:00
alexsparkes
5246455aca feat(ItemSettingsModal): enhance settings UI with improved input styling and dynamic field handling 2026-02-03 21:31:50 +00:00
alexsparkes
ede1615ab8 fix(ItemCard): clear local storage queues and refresh background on pack toggle 2026-02-03 20:31:38 +00:00
alexsparkes
fb9787642b feat(ItemCard): refactor action buttons to use item-card-actions class for consistent styling 2026-02-03 19:54:02 +00:00
alexsparkes
ee89ebcd4d refactor: rename photo pack settings to item settings in modal and styles 2026-02-03 16:36:37 +00:00
alexsparkes
7dec0a844e feat: add ItemSettingsModal for managing photo pack settings and enhance UI interactions 2026-02-03 16:36:05 +00:00
alexsparkes
1a8e91b02b feat: enhance dynamic options loading and add toggle pack functionality 2026-02-03 16:20:14 +00:00
alexsparkes
06e00b3024 fix(ItemCard): adjust toggle switch position and update active background color 2026-02-03 16:11:08 +00:00
alexsparkes
a75a8c2122 oopsie 2026-02-03 15:45:04 +00:00
alexsparkes
bafa2ecbe7 feat: implement pack enable/disable functionality and improve UI responsiveness 2026-02-03 15:45:04 +00:00
alexsparkes
0acac6dcee fix(checkbox+dropdown): small font-size once built 2026-02-03 15:14:45 +00:00
alexsparkes
e7ad7ba131 fix: correct variable reference in goToPhotoPacks function 2026-02-03 12:03:17 +00:00
alexsparkes
a9ab5d9651 feat: enhance BackgroundOptions and PhotoPackSettings for improved offline handling and validation 2026-02-03 12:01:07 +00:00
alexsparkes
09f2e0519d feat: enhance photo pack settings with dynamic localization and improved ChipSelect integration 2026-02-03 10:31:52 +00:00
alexsparkes
4c193cf7db fix: correct Unsplash photo pack ID format in migration settings 2026-02-02 20:59:08 +00:00
alexsparkes
be298266bf fix: correct photo pack ID format in migration settings 2026-02-02 20:20:37 +00:00
alexsparkes
bbaadece43 fix: remove trailing comma in fetch URL for Unsplash API 2026-02-02 20:07:39 +00:00
alexsparkes
d5d2efbd13 feat: update Unsplash API integration and migration settings 2026-02-02 20:07:36 +00:00
alexsparkes
7641762557 feat: implement API photo packs with migration and settings management 2026-02-02 19:51:51 +00:00
alexsparkes
987f6756a0 feat: implement API photo packs with migration and settings management 2026-02-02 19:51:45 +00:00
David Ralph
9040766fe3 chore: capitalise claude.md
Signed-off-by: David Ralph <ohlookitsderpy@protonmail.com>
2026-02-02 16:52:31 +00:00
David Ralph
bbaf4de7f4 fix(settings): experimental flashing on sidebar search 2026-02-02 14:10:27 +00:00
alexsparkes
3fc5d736c8 feat(marketplace): enhance install logic with download tracking and installation checks 2026-02-02 13:29:26 +00:00
alexsparkes
333a070020 feat(Discover, ModalTopBar, MainModal): enhance navigation and iframe handling with hash updates 2026-02-02 12:21:53 +00:00
alexsparkes
cfb27ba392 refactor(ModalTopBar, Discover): clean up breadcrumb handling and hash navigation logic 2026-02-02 10:59:01 +00:00
alexsparkes
99b139ffd6 feat(Discover): implement iframe navigation handling via hash changes 2026-02-02 10:58:52 +00:00
alexsparkes
646f19a78c feat(install): improve photo pack installation logic to prevent immediate background refresh 2026-02-01 20:54:29 +00:00
alexsparkes
9f461edb55 Add new localization strings for quote packs and update caching logic for authors
- Added "Installed Quote Packs" and "Get More" strings to multiple language files.
- Updated the author caching mechanism in wikidataAuthorFetcher.js to limit the number of cached entries to 50 and prune old entries when the limit is exceeded.
- Enhanced the occupation extraction logic to return the highest-ranked occupation based on Wikidata rank.
2026-02-01 20:51:52 +00:00
alexsparkes
8356ea1af5 feat(QuickLinks): add card style option for quick links with hover effects 2026-02-01 20:13:30 +00:00
alexsparkes
a361e6f3d5 feat(AddModal): enhance form functionality with URL suggestion and reset logic 2026-02-01 19:19:28 +00:00
alexsparkes
baea8c4d6c feat: implement bookmark service and quicklinks management
- Added BookmarkService for managing bookmark permissions, fetching bookmarks, and syncing with quicklinks.
- Introduced IconService for handling icon retrieval and custom uploads.
- Created utility functions for reading and writing quicklinks and groups to localStorage.
- Implemented migration logic for transitioning quicklinks data to a new format.
- Added SmartIcon component for displaying icons with fallback options.
- Developed AddModal styles and structure for adding links.
- Enhanced settings loading to support dynamic font loading from Google Fonts.
- Refactored isValidUrl function for improved readability.
- Cleaned up uninstall logic for photo packs in marketplace.
2026-02-01 19:02:32 +00:00
alexsparkes
f42bdf2fb8 feat(QuoteOptions): refactor custom quote UI and styles for improved layout and usability 2026-02-01 15:49:09 +00:00
alexsparkes
fc3092ad44 feat(Sidebar): enhance sidebar layout with search functionality and toggle button 2026-02-01 14:53:47 +00:00
alexsparkes
99bd0a9d35 feat(Tooltip): add floating animations for tooltip appearance based on placement 2026-02-01 14:48:47 +00:00
alexsparkes
d937f8a82e feat: add search functionality to sidebar tabs; update styles for search input and empty state 2026-02-01 14:44:11 +00:00
alexsparkes
cbedaf627c feat: update message keys for settings and overview sections; remove unused permissions and skills from settings 2026-02-01 14:22:01 +00:00
alexsparkes
7c2213a38f feat: integrate Wikidata API for fetching author information, including images and occupations 2026-02-01 11:51:11 +00:00
David Ralph
e25c8dedb7 feat(translations): add manfiest sync, remove debug 2026-02-01 11:45:53 +00:00
David Ralph
b54c573c8e cleanup(translations): remove unused keys, add finder script 2026-02-01 11:40:48 +00:00
alexsparkes
bba18acd19 feat: implement safe JSON parsing and caching utilities; refactor background and quote loaders 2026-02-01 11:24:02 +00:00
David Ralph
f534d531dc fix(translations): add missing custom widget objects 2026-02-01 11:20:22 +00:00
alexsparkes
a017632597 feat: add project guidelines for GitHub Copilot 2026-02-01 10:49:24 +00:00
alexsparkes
8843178885 feat: add Copilot guidelines and project overview to repository 2026-02-01 10:49:21 +00:00
alexsparkes
5ce553674c fix(locales): simplify events structure in greeting translations 2026-02-01 09:56:25 +00:00
alexsparkes
f7a799fe34 feat: add update frequency settings for quotes and backgrounds
- Introduced frequency settings for quotes and background updates in multiple languages.
- Added new localization keys for "Update Frequency" and its options.
- Implemented a new hook `useFrequencyInterval` to manage update intervals based on user settings.
- Created a `frequencyManager` utility to handle frequency intervals and update logic.
- Updated default settings to include frequency preferences for both quotes and backgrounds.
2026-01-31 17:44:29 +00:00
alexsparkes
7415b9cd5c feat: add DuckDuckGo image proxy support for marketplace and photo pack images
- Implemented `getProxiedImageUrl` function to apply DuckDuckGo image proxy to image URLs based on user settings.
- Updated various components to use the proxied image URLs, including background loader, photo information, lightbox, and item cards.
- Added a new setting in the advanced options to enable/disable the DuckDuckGo image proxy.
- Updated localization files to include new strings for the image proxy feature.
- Initialized the proxy setting in default settings to false.
2026-01-31 16:54:55 +00:00
alexsparkes
41e438ead4 feat(queue): implement centralized queue management and clearing logic for background prefetch queues 2026-01-31 14:58:07 +00:00
alexsparkes
7251c16250 feat(quote): clear quote queue and cache on type change, custom quote modification, and pack installation/uninstallation 2026-01-31 14:04:01 +00:00
David Ralph
21301d8a2a fix(translations): replace temp dev tab name 2026-01-30 23:45:11 +00:00
David Ralph
b742c6a768 feat: custom iframe widgets (close #656) 2026-01-30 23:39:05 +00:00
alexsparkes
f29b879215 feat(greeting, date): enhance turkish localisation support for date 2026-01-29 22:26:02 +00:00
alexsparkes
09308a4452 feat(i18n): add localised versions of the mue brand name 2026-01-29 18:31:42 +00:00
David Ralph
e03b1d68d0 fix(quicklinks): close #875 by enabling offline 2026-01-28 16:52:08 +00:00
David Ralph
3e434341da chore: merge branch 'dev' of https://github.com/mue/mue into dev 2026-01-28 16:45:49 +00:00
David Ralph
49630ee375 fix: quick link input, close #1124 2026-01-28 16:45:41 +00:00
alexsparkes
9d98e2124a feat(CustomSettings): replace video icon and add overlay icon for custom backgrounds 2026-01-28 16:44:27 +00:00
alexsparkes
0c9a90d693 fix: comment out box-shadow on hover effect for sortable items 2026-01-28 16:37:03 +00:00
alexsparkes
6c73bdb156 fix(modal): weird cutting off of bottom content 2026-01-28 16:31:36 +00:00
David Ralph
6465b88f30 fix: custom video background 2026-01-28 16:27:24 +00:00
David Ralph
a4e575c5f6 feat: library display option, navbar count 2026-01-28 15:49:22 +00:00
alexsparkes
05bf8edeea feat(WeatherOptions): refactor weatherType state management and update onChange handler 2026-01-28 14:02:31 +00:00
alexsparkes
50aae0f8ec feat(LocationSearch): add LocationSearch component for improved location input 2026-01-28 13:43:29 +00:00
alexsparkes
6e852eb252 feat(Appearance): add Google Fonts selection to font options 2026-01-28 11:51:42 +00:00
alexsparkes
6cb8843b49 feat(SidebarToggle): replace menu icon with sidebar icon and update button accessibility
feat(buttons): enhance button styles with active state transformations and transitions
fix(Header): add delay for setting change to ensure button click animation completes
2026-01-27 23:07:37 +00:00
alexsparkes
63b8742218 feat(Sidebar): implement collapsible sidebar with toggle functionality and update translations 2026-01-27 22:59:52 +00:00
alexsparkes
bebd551193 feat(Modal): add modal border variable and update themed styles 2026-01-27 22:33:45 +00:00
alexsparkes
c71c6442db feat(Dropdown): optimize position calculation and add scroll/resize handling 2026-01-27 22:14:45 +00:00
alexsparkes
9e692b97ce feat(Slider): add hover functionality with tooltip and indicator 2026-01-27 21:40:12 +00:00
David Ralph
7f0b37c713 feat: new date picker 2026-01-27 20:42:19 +00:00
David Ralph
4d8be5774f style: improve dropdown default 2026-01-27 20:26:41 +00:00
David Ralph
b7097979de fix: moreSettings misfiring on dropdown click 2026-01-27 20:10:09 +00:00
David Ralph
7e6bc58f2c feat: new dropdown search capability 2026-01-27 20:04:58 +00:00
alexsparkes
9fa1ddcab5 feat(ModalTabContent): add gap to settings row for improved layout 2026-01-27 16:51:04 +00:00
alexsparkes
3dad52196d feat(Radio): update header structure and styling for improved accessibility
feat(Dropdown): remove margin and clean up unused styles
style(ModalTabContent): adjust padding and add gap for button layout
2026-01-27 16:49:03 +00:00
alexsparkes
df23753971 feat(Dropdown): add default value indication and style for dropdown options 2026-01-27 16:34:49 +00:00
alexsparkes
401e711bd8 feat(ChipSelect): enhance dropdown with closing animations and position calculation 2026-01-27 16:29:07 +00:00
alexsparkes
f493eb186e feat(Dropdown): enhance dropdown menu with position calculation and closing animations 2026-01-27 16:23:54 +00:00
Alex Sparkes
312ef49f78 Dev (#1139)
* fix: add blurhash dependency for image metadata encoding

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

fix(QuoteOptions): ensure authorDetails is set to true for users upgrading from older versions

* chore: bump version to 7.6.1 in package.json, manifest.json, project.pbxproj, and constants.js

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-27 14:31:40 +00:00
Alex Sparkes
79c8e1508f Merge branch 'beta' into dev
Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-27 14:28:50 +00:00
alexsparkes
b5a451c70d chore: bump version to 7.6.1 in package.json, manifest.json, project.pbxproj, and constants.js 2026-01-27 14:27:27 +00:00
alexsparkes
7a589de14b feat(Dropdown): implement closing animation and portal rendering for dropdown menu
fix(QuoteOptions): ensure authorDetails is set to true for users upgrading from older versions
2026-01-27 14:22:15 +00:00
alexsparkes
9bf160094e fix: add blurhash dependency for image metadata encoding 2026-01-27 12:29:34 +00:00
alexsparkes
f8746a31b0 fix: add blurhash dependency for image metadata encoding 2026-01-27 12:27:18 +00:00
alexsparkes
864097c508 refactor(Items): remove unused imports and hex color conversion logic 2026-01-27 12:15:12 +00:00
Alex Sparkes
139c8e2914 Merge branch 'beta' into dev
Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
2026-01-27 12:10:35 +00:00
alexsparkes
f2a0330655 fix(QuoteOptions): ensure authorDetails is set to true for all users during migration 2026-01-27 12:06:45 +00:00
alexsparkes
89523df1cf feat(Dropdown): implement dropdown closing animation and portal rendering 2026-01-27 10:21:02 +00:00
alexsparkes
9462fe1b32 fix(Custom): remove unnecessary characters from loading state 2026-01-27 10:13:08 +00:00
alexsparkes
c7a2760709 feat(modal): enhance close button styling and theming support 2026-01-26 22:32:45 +00:00
alexsparkes
f1e961e8e4 feat(storage): implement dynamic storage quota estimation and request persistence 2026-01-26 16:48:47 +00:00
alexsparkes
616055106b fix(background/custom): prevent flashing during uploads 2026-01-26 16:44:10 +00:00
alexsparkes
9677434c00 Add new localization strings and improve image metadata utility functions
- Updated localization files for multiple languages (Hungarian, Indonesian, Japanese, Lithuanian, Latvian, Dutch, Norwegian, Persian, Portuguese, Brazilian Portuguese, Russian, Slovenian, Swedish, Tamil, Turkish, Ukrainian, Vietnamese, Simplified Chinese, Traditional Chinese) to include new strings for image management features such as "Delete Selected", "Uploading", "Tag Images", and storage information.
- Enhanced the `getDataUrlSize` and `formatBytes` functions in `imageMetadata.js` for better readability and maintainability by adding braces for conditional statements.
2026-01-26 16:28:13 +00:00
alexsparkes
cac58cdaeb feat: enhance image management features
- Added new localization strings for image management, including upload and storage information.
- Refactored custom background database functions to support metadata and backward compatibility.
- Introduced a new FolderTaggingModal component for organizing images into folders.
- Created utility functions for image metadata extraction, including dimensions, blur hash generation, and file size calculation.
- Implemented functions to delete multiple backgrounds and update background metadata.
2026-01-26 16:14:09 +00:00
alexsparkes
e42a218116 feat(background): implement custom background loading and improve state management 2026-01-26 12:24:26 +00:00
alexsparkes
40c248985d fix(quote/buttons): improve state management and event handling 2026-01-26 10:26:02 +00:00
alexsparkes
d88ed2eedd fix(greeting/events): event text box styling 2026-01-26 10:21:51 +00:00
David Ralph
ab2b969772 cleanup: remove unused code from addons and marketplace 2026-01-25 21:28:26 +00:00
David Ralph
67ba0f6718 font: replace montserrat with inter 2026-01-25 21:17:38 +00:00
Alex Sparkes
4cf5269cdc Dev (#1134)
* feat: add professional three-branch release workflow automation (#1129)

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

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

* feat: new default quotes experience, improve added page

* Fix/beta workflow version check (#1131)

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

* fix(workflows): address copilot PR review feedback

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

* feat: replace mui with new style

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

* Fix/beta workflow version check (#1131)

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

* fix(workflows): address copilot PR review feedback

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

---------

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

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

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true
}
}

View File

@@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["Bash(perl -i -pe:*)", "Bash(bun run:*)"]
}
}

153
.github/.copilot-instructions.md vendored Normal file
View 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

View File

@@ -1 +1,12 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
target-branch: "dev"
labels:
- "dependencies"
commit-message:
prefix: "chore"
include: "scope"

View File

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

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- beta
tags:
- "v*-beta.*"
permissions:
contents: write
@@ -21,7 +23,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
bun-version: "1.3.1"
- name: Install dependencies
run: bun install
@@ -37,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

View File

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

View File

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

View File

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

View File

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

@@ -29,6 +29,7 @@ safari/DerivedData/
safari/build/
# Files
unused-translations.txt
package-lock.json
.stylelintcache
yarn-error.log

View File

@@ -1 +1,2 @@
bunx --bun commitlint --edit $1
#!/bin/sh
bunx --bun commitlint --edit "$1"

113
CLAUDE.md Normal file
View 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`.

View File

@@ -263,7 +263,7 @@ When beta is stable:
7. Manually trigger store submission:
```
Actions → Submit → Run workflow
- Enter version tag (e.g., v7.6.0)
- Enter version tag (e.g., 7.6.0)
```
#### 4. Emergency Hotfix

View File

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

1578
bun.lock

File diff suppressed because it is too large Load Diff

12
cypress.config.js Normal file
View 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
View 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
View 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');
});
});

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

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

View 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
View 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')

View File

@@ -184,7 +184,7 @@ Before starting any release:
- Check build artifacts
- **Approve deployment** in Environments → production
4. **Review period** - Workflow waits for your approval (10 min deployment protection)
4. **Wait 10 minutes** (cooldown period)
5. **Release completes**:
- GitHub release published
@@ -197,7 +197,7 @@ Before starting any release:
1. Go to **Actions****Submit****Run workflow**
2. Enter version tag: `v7.6.0` (include the 'v' prefix to match the release tag)
2. Enter version tag: `7.6.0` (no 'v' prefix)
3. Click **Run workflow**

View File

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

View File

@@ -12,6 +12,7 @@
"@/*": ["./*"],
"i18n/*": ["./i18n/*"],
"components/*": ["./components/*"],
"hooks/*": ["./hooks/*"],
"assets/*": ["./assets/*"],
"config/*": ["./config/*"],
"features/*": ["./features/*"],

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

@@ -0,0 +1,8 @@
{
"name": {
"message": "Mue"
},
"description": {
"message": "Fast, open and free-to-use new tab page for modern browsers."
}
}

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
"homepage": "https://muetab.com",
"bugs": "https://github.com/mue/mue/issues/new?assignees=&labels=bug&template=bug-report.md&title=%5BBUG%5D",
"license": "BSD-3-Clause",
"version": "7.5.0",
"version": "7.6.1",
"type": "module",
"packageManager": "bun@1.3.1",
"engines": {
@@ -21,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"
}
}
}

View File

@@ -4,7 +4,7 @@
"default_locale": "en",
"name": "__MSG_name__",
"description": "__MSG_description__",
"version": "7.5.0",
"version": "7.6.1",
"homepage_url": "https://muetab.com",
"permissions": ["search"],
"chrome_url_overrides": {

View File

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

View File

@@ -255,7 +255,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 7.5.0;
MARKETING_VERSION = 7.6.1;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -289,7 +289,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 7.5.0;
MARKETING_VERSION = 7.6.1;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -445,7 +445,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 7.5.0;
MARKETING_VERSION = 7.6.1;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@@ -486,7 +486,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 7.5.0;
MARKETING_VERSION = 7.6.1;
OTHER_LDFLAGS = (
"-framework",
SafariServices,

View File

@@ -1,11 +1,11 @@
{
"colors" : [
"colors": [
{
"idiom" : "universal"
"idiom": "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
import variables from 'config/variables';
import { Button } from 'components/Elements';
import { NAVBAR_BUTTONS } from '../constants/tabConfig';
const ModalNavbar = ({ currentTab, onChangeTab }) => (
<div className="modalNavbar">
{NAVBAR_BUTTONS.map(({ tab, icon: Icon, messageKey }) => (
<Button
key={tab}
type="navigation"
onClick={() => onChangeTab(tab)}
icon={<Icon />}
label={variables.getMessage(messageKey)}
active={currentTab === tab}
/>
))}
</div>
);
export default ModalNavbar;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
import { FiSidebar } from 'react-icons/fi';
import { Tooltip } from 'components/Elements';
import { useT } from 'contexts/TranslationContext';
function SidebarToggle({ isCollapsed, onToggle }) {
const t = useT();
return (
<Tooltip
title={isCollapsed ? t('modals.main.sidebar.expand') : t('modals.main.sidebar.collapse')}
placement="right"
>
<button
className="sidebarToggleButton"
onClick={onToggle}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!isCollapsed}
>
<FiSidebar />
</button>
</Tooltip>
);
}
export default SidebarToggle;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,182 +0,0 @@
@use 'scss/variables' as *;
.side {
float: right;
margin-left: 20px;
}
p.description {
margin-top: 0;
max-width: 800px;
}
.moreInfo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 30px;
.items {
margin-top: 0 !important;
}
.item {
flex: 1 0 40% !important;
}
.infoItem {
display: flex;
flex-flow: row;
align-items: center;
gap: 15px;
svg {
@include themed {
background-image: t($slightGradient);
box-shadow: t($boxShadow);
}
font-size: 18px;
padding: 7px;
border-radius: 100%;
}
.text {
display: flex;
flex-flow: column;
font-size: medium;
}
}
.header {
// text-transform: uppercase;
font-size: small;
@include themed {
color: t($subColor);
}
}
span {
@include themed {
color: t($color);
}
}
@include themed {
background: t($modal-secondaryColour);
box-shadow: 0 0 0 1px t($modal-sidebarActive);
border-radius: t($borderRadius);
padding: 15px;
}
}
.subHeader {
display: flex;
flex-flow: row;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
gap: 25px;
.itemWarning {
padding: 10px 20px;
display: flex;
flex-flow: row;
gap: 15px;
align-items: center;
.text {
display: flex;
flex-flow: column;
}
svg {
@include themed {
background-image: t($slightGradient);
box-shadow: t($boxShadow);
}
padding: 7px;
border-radius: 100%;
}
@include themed {
background: t($modal-sidebar);
border-radius: t($borderRadius);
box-shadow: 0 0 0 1px t($modal-sidebarActive);
}
}
.items {
margin-top: 0 !important;
}
.item {
flex: 1 0 40% !important;
}
.infoItem {
display: flex;
flex-flow: row;
align-items: center;
gap: 15px;
flex: 1 0 44%;
svg {
@include themed {
background-image: t($slightGradient);
box-shadow: t($boxShadow);
}
font-size: 18px;
padding: 7px;
border-radius: 100%;
}
.text {
font-size: medium;
display: flex;
flex-flow: column;
}
}
.header {
font-size: small;
@include themed {
color: t($subColor);
}
}
span {
@include themed {
color: t($color);
}
}
}
.showMoreItems {
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
gap: 10px;
}
.marketplaceDescription {
display: flex;
flex-flow: column;
gap: 15px;
.subtitle {
-webkit-user-select: text !important;
user-select: text !important;
}
}
.moreFromCurator {
margin-top: 50px;
display: flex;
flex-flow: column;
gap: 15px;
}

View File

@@ -1,5 +1,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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export { SmartIcon } from './SmartIcon';

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import variables from 'config/variables';
import { memo, useState, useCallback } from 'react';
import { Checkbox as CheckboxUI, FormControlLabel } from '@mui/material';
import { MdCheck } from 'react-icons/md';
import EventBus from 'utils/eventbus';
import './Checkbox.scss';
const Checkbox = memo((props) => {
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
@@ -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>
);
});

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

View File

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