Compare commits

...

1 Commits

Author SHA1 Message Date
Alex Sparkes
e5d8bfec0e Beta (#1138)
* feat: add professional three-branch release workflow automation (#1129) (#1130)

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

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

* feat: new default quotes experience, improve added page

* Sync/workflow fixes to beta (#1132)

* feat: add professional three-branch release workflow automation (#1129)

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

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

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

* Fix/beta workflow version check (#1131)

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

* fix(workflows): address copilot PR review feedback

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

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>

* feat: replace mui with new style

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

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

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

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

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

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

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

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

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

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

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

* Dev (#1134)

* feat: add professional three-branch release workflow automation (#1129)

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

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

* feat: new default quotes experience, improve added page

* Fix/beta workflow version check (#1131)

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

* fix(workflows): address copilot PR review feedback

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

* feat: replace mui with new style

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

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

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

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

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

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

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

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

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

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

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

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
Co-authored-by: David Ralph <me@davidcralph.co.uk>

* font: replace montserrat with inter

* cleanup: remove unused code from addons and marketplace

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

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

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

* feat: enhance image management features

- Added new localization strings for image management, including upload and storage information.
- Refactored custom background database functions to support metadata and backward compatibility.
- Introduced a new FolderTaggingModal component for organizing images into folders.
- Created utility functions for image metadata extraction, including dimensions, blur hash generation, and file size calculation.
- Implemented functions to delete multiple backgrounds and update background metadata.

* Add new localization strings and improve image metadata utility functions

- Updated localization files for multiple languages (Hungarian, Indonesian, Japanese, Lithuanian, Latvian, Dutch, Norwegian, Persian, Portuguese, Brazilian Portuguese, Russian, Slovenian, Swedish, Tamil, Turkish, Ukrainian, Vietnamese, Simplified Chinese, Traditional Chinese) to include new strings for image management features such as "Delete Selected", "Uploading", "Tag Images", and storage information.
- Enhanced the `getDataUrlSize` and `formatBytes` functions in `imageMetadata.js` for better readability and maintainability by adding braces for conditional statements.

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

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

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

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

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

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

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

* fix: add blurhash dependency for image metadata encoding

---------

Signed-off-by: Alex Sparkes <alexsparkes@gmail.com>
Co-authored-by: David Ralph <me@davidcralph.co.uk>
2026-01-27 12:35:18 +00:00
103 changed files with 5067 additions and 2641 deletions

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- beta
tags:
- 'v*-beta.*'
permissions:
contents: write
@@ -37,13 +39,6 @@ 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

View File

@@ -176,7 +176,7 @@ jobs:
echo "### ⚠️ Manual Steps Required" >> $GITHUB_STEP_SUMMARY
echo "1. Go to [GitHub Actions](https://github.com/${{ github.repository }}/actions/workflows/submit.yml)" >> $GITHUB_STEP_SUMMARY
echo "2. Click 'Run workflow'" >> $GITHUB_STEP_SUMMARY
echo "3. Enter tag: \`v${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "3. Enter tag: \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "4. Click 'Run workflow' to submit to Chrome/Firefox/Edge stores" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📢 Post-Release Checklist" >> $GITHUB_STEP_SUMMARY

View File

@@ -54,12 +54,6 @@ jobs:
BASE_VERSION=$(echo $CURRENT_VERSION | sed 's/-.*$//')
IFS='.' read -r -a VERSION_PARTS <<< "$BASE_VERSION"
# Detect if current version is already a pre-release
IS_PRERELEASE=false
case "$CURRENT_VERSION" in
*-*) IS_PRERELEASE=true ;;
esac
MAJOR="${VERSION_PARTS[0]}"
MINOR="${VERSION_PARTS[1]}"
PATCH="${VERSION_PARTS[2]}"

View File

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

1329
bun.lock

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Mue",
"description": "Fast, open and free-to-use new tab page for modern browsers.",
"version": "7.5.0",
"version": "7.6.0",
"homepage_url": "https://muetab.com",
"action": {
"default_icon": "icons/128x128.png"

View File

@@ -9,7 +9,7 @@
"homepage": "https://muetab.com",
"bugs": "https://github.com/mue/mue/issues/new?assignees=&labels=bug&template=bug-report.md&title=%5BBUG%5D",
"license": "BSD-3-Clause",
"version": "7.5.0",
"version": "7.6.0",
"type": "module",
"packageManager": "bun@1.3.1",
"engines": {
@@ -21,18 +21,14 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@eartharoid/i18n": "1.2.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@floating-ui/react-dom": "2.1.6",
"@fontsource/inter": "^5.2.8",
"@fontsource/lexend-deca": "5.0.14",
"@fontsource/montserrat": "5.0.19",
"@mui/material": "7.3.7",
"@sentry/react": "^10.36.0",
"embla-carousel-autoplay": "8.6.0",
"embla-carousel-react": "8.6.0",
"blurhash": "^2.0.5",
"fast-blurhash": "^1.1.4",
"image-conversion": "^2.1.1",
"mue": "file:",
"react": "^19.2.3",
"react-best-gradient-color-picker": "^3.0.14",
"react-clock": "6.0.0",

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import variables from 'config/variables';
import { useState, memo } from 'react';
import { TextareaAutosize } from '@mui/material';
import { MdAddLink, MdClose } from 'react-icons/md';
import { Tooltip } from 'components/Elements';
import { Button } from 'components/Elements';
@@ -26,22 +25,24 @@ function AddModal({ urlError, iconError, addLink, closeModal, edit, editData, ed
</Tooltip>
</div>
<div className="quicklinkModalTextbox">
<TextareaAutosize
maxRows={1}
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('widgets.quicklinks.name')}
value={name}
onChange={(e) => setName(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
style={{ gridColumn: 'span 2' }}
/>
<TextareaAutosize
maxRows={10}
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('widgets.quicklinks.url')}
value={url}
onChange={(e) => setUrl(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}
/>
<TextareaAutosize
maxRows={10}
maxLines={1}
<input
type="text"
className="text-field-input"
placeholder={variables.getMessage('widgets.quicklinks.icon')}
value={icon}
onChange={(e) => setIcon(e.target.value.replace(/(\r\n|\n|\r)/gm, ''))}

View File

@@ -3,6 +3,7 @@
@use 'modules/topBar' as *;
@use 'modules/sidebar' as *;
@use 'modules/navbar' as *;
@use 'modules/buttons' as *;
@use 'modules/modalTabContent' as *;
@use 'modules/links' as *;
@use 'modules/scrollbars' as *;
@@ -57,11 +58,18 @@
border-radius: 12px;
cursor: pointer;
transition: 0.5s;
outline: none;
border: none;
background: transparent;
svg {
font-size: 2em;
}
@include themed {
color: t($color);
}
&:hover {
@include themed {
background: t($modal-sidebarActive);
@@ -206,7 +214,7 @@ h5 {
}
.languageSettings {
margin-bottom: 15px;
padding-bottom: 50px;
.MuiFormGroup-root {
gap: 5px;

View File

@@ -1,27 +1,10 @@
// this file is too long
@use 'modules/item' as *;
@use 'modules/buttons' as *;
@use 'modules/lightbox' as *;
@use 'scss/variables' as *;
.creatorItems {
.item {
flex-flow: row !important;
}
.item-icon {
margin: 0 !important;
}
.card-details {
margin: 0 !important;
text-align: left;
}
}
.items {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 280px));
grid-gap: 1.5rem;
margin-top: 15px;
margin-bottom: 30px;
@@ -62,6 +45,20 @@
width: 60px !important;
border-radius: 12px;
transition: 0.5s;
&.item-icon-text {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
letter-spacing: 1px;
@include themed {
background-color: t($modal-sidebarActive);
color: t($color);
}
}
}
.card-details {
@@ -113,6 +110,28 @@
}
}
.item-uninstall-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background-color: rgba(0, 0, 0, 0.5);
cursor: pointer;
transition: background-color 0.2s ease;
svg {
color: white;
font-size: 18px;
}
&:hover {
background-color: rgba(220, 50, 50, 0.9);
}
}
.item-installed-badge {
position: absolute;
top: 12px;
@@ -135,9 +154,33 @@
}
}
.item-sideload-badge {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(100, 100, 100, 0.9);
cursor: help;
svg {
color: white;
font-size: 16px;
}
}
&:hover .item-installed-badge {
transform: scale(1.05);
}
&.item-sideloaded {
cursor: default;
&:hover {
transform: none;
}
}
}
}
@@ -171,154 +214,29 @@
.itemTop {
display: flex;
flex-direction: column;
gap: 18px;
}
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(100, 100, 100, 0.9);
cursor: help;
.itemTabs {
display: flex;
flex-wrap: wrap;
gap: 12px;
.itemTab {
border-radius: 999px;
padding: 0.55rem 1.3rem;
border: 1px solid transparent;
background: transparent;
font-weight: 600;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
@include themed {
color: t($subColor);
border-color: rgb(255 255 255 / 10%);
background-color: rgb(255 255 255 / 5%);
}
&:hover {
transform: translateY(-1px);
@include themed {
border-color: rgb(255 255 255 / 18%);
}
}
&.active {
@include themed {
color: t($color);
background-color: t($modal-sidebarActive);
border-color: transparent;
}
}
svg {
color: white;
font-size: 16px;
}
}
.tabContent {
display: flex;
flex-direction: column;
gap: 25px;
&:hover .item-installed-badge {
transform: scale(1.05);
}
.itemHighlights {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 18px;
&.item-sideloaded {
cursor: default;
.highlightCard {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px;
border-radius: 14px;
border: 1px solid transparent;
@include themed {
background-color: t($modal-secondaryColour);
border-color: rgb(255 255 255 / 8%);
box-shadow: 0 0 0 1px t($modal-sidebarActive);
}
.highlightLabel {
font-size: 14px;
@include themed {
color: t($subColor);
}
}
.highlightValue {
font-size: 30px;
font-weight: 600;
@include themed {
color: t($color);
}
}
}
}
.marketplaceDetails {
.moreInfo {
margin-top: 10px;
}
}
}
.itemInfo {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 15px;
flex: 0 0 300px;
width: 300px;
max-width: 100%;
max-height: 700px;
.front {
padding: 20px;
height: 100%;
display: flex;
flex-flow: column;
gap: 15px;
width: 100%;
box-sizing: border-box !important;
border-radius: 12px 12px 0 0;
-webkit-backdrop-filter: blur(40px) saturate(150%) brightness(75%);
backdrop-filter: blur(40px) saturate(150%) brightness(75%);
@include themed {
background-image: linear-gradient(to bottom, transparent, t($modal-background));
}
}
.icon {
width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 5px 25px black;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.divider {
text-transform: uppercase;
@include themed {
color: t($subColor);
}
}
.iconButtons {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
grid-gap: 20px;
button {
width: 100%;
padding: 0;
&:hover {
transform: none;
}
}
}
@@ -408,35 +326,6 @@ p.author {
cursor: pointer;
}
.returnButton {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 12px;
cursor: pointer;
svg {
font-size: 2em;
}
&:hover {
background: rgb(121 121 121 / 22.6%);
}
}
.flexTopMarketplace {
display: flex;
margin-bottom: 15px;
.tooltip {
margin-right: 25px;
}
.mainTitle {
margin-bottom: 0 !important;
}
}
.filter {
display: flex;
@@ -460,147 +349,6 @@ p.author {
}
}
.collectionPage {
// height: 200px;
padding: 1.5rem;
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: 15px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@include themed {
border-radius: t($borderRadius);
}
.nice-tag {
border-radius: 150px;
padding: 1px 12px;
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
background-color: rgb(255 255 255 / 10%);
border: 1px solid rgb(209 213 219 / 30%);
color: #fff;
}
.content {
display: flex;
flex-flow: column;
text-align: center;
text-shadow: #000 0 0 15px;
.mainTitle {
justify-content: center;
color: #fff !important;
}
.subtitle {
color: #ccc !important;
}
}
}
.collection {
display: flex;
justify-content: space-between;
padding: 36px 48px;
margin: 15px 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
align-items: center;
@include themed {
box-shadow: 0 0 0 1px t($modal-sidebarActive);
border-radius: t($borderRadius);
}
.content {
display: flex;
flex-flow: column;
gap: 15px;
max-width: 250px;
text-shadow: #000 0 0 15px;
.title {
color: #fff !important;
}
.subtitle {
color: #ccc !important;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
}
}
.items {
justify-content: center;
}
}
.marketplaceRefresh {
display: flex;
flex-flow: row;
gap: 5px;
align-items: center;
}
.marketplaceSearch {
display: flex;
align-items: center;
padding: 10px 30px;
border-radius: 10px;
font-size: 18px;
@include themed {
box-shadow: 0 0 0 3px t($modal-sidebarActive);
background: t($modal-sidebar);
}
input {
all: unset;
}
@include themed {
&:focus-within {
background: t($modal-sidebarActive);
box-shadow: 0 0 0 1px t($color);
}
&:disabled {
background: t($modal-sidebarActive);
cursor: not-allowed;
}
}
}
.inCollection {
// background-image: linear-gradient(to left, transparent, #000),
// url('https://external-preview.redd.it/JyhsEoGMhKIMi3kvfBS24L0IllAO_KrIm4UI-dA1Ax4.jpg?auto=webp&s=b5adf9859b2c1855a5b3085f9453a6e878548505');
display: flex;
flex-flow: column;
gap: 5px;
padding: 5px;
margin: 10px 0;
@include themed {
// background-color: t($modal-secondaryColour);
// box-shadow: 0 0 0 1px t($modal-sidebarActive);
border-radius: t($borderRadius);
}
.title:hover {
cursor: pointer;
text-decoration: underline;
}
}
.createYourOwn {
display: flex;
flex-flow: column;

View File

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

View File

@@ -213,6 +213,30 @@ table {
}
}
input[type='text'] {
height: 56px;
padding: 0 16px;
font-size: 16px;
outline: none;
transition: 0.2s ease;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover,
&:focus {
border-color: t($color);
}
&::placeholder {
color: t($subColor);
}
}
}
.subtitle {
@include themed {
color: t($subColor);
@@ -264,7 +288,8 @@ table {
padding-left: 10px;
padding-right: 5px;
input[type='tel'] {
input[type='tel'],
input[type='number'] {
background: none;
outline: none;
border: none;

View File

@@ -92,13 +92,20 @@ h4 {
}
.imagesTopBar {
padding-top: 25px;
position: sticky;
top: -20px;
z-index: 90;
padding: 25px 0 15px 0;
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
div:nth-child(1) {
@include themed {
background: t($modal-background);
}
.imagesTopBarTitle {
display: flex;
flex-flow: row;
align-items: center;
@@ -121,18 +128,139 @@ h4 {
.topbarbuttons {
display: flex;
flex-flow: row;
gap: 25px;
gap: 15px;
align-items: center;
}
button {
button:not(.MuiButtonBase-root) {
padding: 0 20px;
}
}
.imagesControlBar {
position: sticky;
top: 68px;
z-index: 89;
padding: 12px 0;
margin-bottom: 15px;
display: flex;
flex-flow: row;
justify-content: space-between;
align-items: center;
@include themed {
background: t($modal-background);
border-bottom: 1px solid t($modal-sidebarActive);
}
.controlBarLeft {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
@include themed {
color: t($subColor);
}
.image-count {
font-weight: 500;
display: flex;
@include themed {
color: t($color);
}
.storage-info {
font-weight: 400;
@include themed {
color: t($subColor);
}
.request-storage-link {
background: none;
border: none;
padding: 0;
margin-left: 5px;
cursor: pointer;
text-decoration: underline;
font-size: 13px;
transition: opacity 0.2s;
@include themed {
color: #ff5c25;
}
&:hover {
opacity: 0.8;
}
}
}
}
.selection-separator {
opacity: 0.5;
}
.selected-count {
font-weight: 500;
}
.delete-link {
background: none;
border: none;
padding: 0;
margin-left: 5px;
cursor: pointer;
text-decoration: underline;
font-size: 14px;
transition: opacity 0.2s;
@include themed {
color: rgb(255 71 87);
}
&:hover {
opacity: 0.8;
}
}
.select-all-link {
background: none;
border: none;
padding: 0;
margin-left: 5px;
cursor: pointer;
text-decoration: underline;
font-size: 14px;
transition: opacity 0.2s;
@include themed {
color: t($subColor);
}
&:hover {
opacity: 0.8;
@include themed {
color: t($color);
}
}
}
}
.controlBarRight {
display: flex;
align-items: center;
}
}
.customcss textarea {
font-family: Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console',
'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono',
'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace !important;
font-family:
Consolas, 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter',
'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco,
'Courier New', Courier, monospace !important;
}
.preferences {

View File

@@ -95,6 +95,413 @@
}
}
// Enhanced custom images grid
.images-grid {
display: grid;
padding: 1px;
// Show all checkboxes when in selection mode (any image selected)
&.selection-mode {
.image-checkbox {
opacity: 1;
}
}
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
@include themed {
.image-card {
position: relative;
border-radius: t($borderRadius);
background: t($modal-secondaryColour);
overflow: hidden;
transition: all 0.3s ease;
box-shadow: t($boxShadow);
cursor: pointer;
&.selected {
outline: 3px solid #ff5c25;
outline-offset: -3px;
}
&:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
.image-nav-buttons {
opacity: 1;
}
.delete-button {
opacity: 1;
}
.image-checkbox {
opacity: 1;
}
}
.image-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 12;
opacity: 0;
transition: opacity 0.2s;
input[type='checkbox'] {
width: 20px;
height: 20px;
cursor: pointer;
appearance: none;
border: 2px solid #fff;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
position: relative;
&:checked {
background: #ff5c25;
border-color: #ff5c25;
opacity: 1;
&::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
&:hover {
border-color: #ff5c25;
transform: scale(1.1);
}
}
// Keep checkbox visible when checked
&:has(input:checked) {
opacity: 1;
}
}
.image-preview {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
background: t($modal-sidebar);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-icon-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: t($modal-sidebar);
.customvideoicon {
font-size: 60px;
color: t($subColor);
}
}
.blur-placeholder {
background-size: cover;
background-position: center;
}
.image-nav-buttons {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
padding: 0 8px;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
.nav-button {
pointer-events: all;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
svg {
font-size: 24px;
}
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}
}
.image-metadata {
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
.image-name {
font-size: 14px;
font-weight: 500;
color: t($color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.image-details {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: t($subColor);
.detail {
display: inline-block;
}
.folder-tag {
padding: 2px 8px;
background: t($modal-sidebarActive);
border-radius: 4px;
font-size: 11px;
}
}
}
.delete-button {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: rgba(255, 71, 87, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s;
z-index: 11;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
svg {
font-size: 20px;
}
&:hover {
background: rgb(255 71 87);
transform: scale(1.1);
}
}
// Show delete button when card is hovered or has checkbox visible
&:hover .delete-button,
.image-checkbox:has(input:checked) ~ * .delete-button {
opacity: 1;
}
}
}
}
// Storage quota display
.storage-quota {
padding: 15px 20px;
margin-top: 10px;
margin-bottom: 50px;
@include themed {
background: t($modal-secondaryColour);
border-top: 1px solid t($modal-sidebarActive);
}
.quota-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.quota-text {
font-size: 13px;
@include themed {
color: t($subColor);
}
}
.quota-info-button {
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
@include themed {
color: t($subColor);
&:hover {
background: t($modal-sidebarActive);
}
}
svg {
font-size: 18px;
}
}
}
.quota-bar {
width: 100%;
height: 6px;
border-radius: 3px;
overflow: hidden;
@include themed {
background: t($modal-sidebar);
}
.quota-fill {
height: 100%;
border-radius: 3px;
transition: all 0.3s ease;
}
}
}
// Folder tagging modal styles
.taggingModalContent {
padding: 20px;
p.subtitle {
margin-bottom: 20px;
}
.taggingInput {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
@include themed {
color: t($subColor);
}
}
input {
padding: 12px 16px;
border-radius: 8px;
border: 1px solid;
font-size: 14px;
transition: all 0.2s;
@include themed {
background: t($modal-background);
color: t($color);
border-color: t($modal-sidebarActive);
&:focus {
border-color: #ff5c25;
box-shadow: 0 0 0 3px rgba(255, 92, 37, 0.1);
}
}
}
}
}
.dropzone {
margin-bottom: 100px;
@include themed {
background: t($modal-background);
}
.dropzone-content {
min-height: 200px;
}
.photosEmpty {
padding: 60px 20px;
display: flex;
align-items: center;
justify-content: center;
.emptyNewMessage {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
text-align: center;
.title {
font-size: 18px;
font-weight: 500;
@include themed {
color: t($color);
}
}
.subtitle {
font-size: 14px;
@include themed {
color: t($subColor);
}
}
}
}
}
.overviewGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));

View File

@@ -1,9 +1,11 @@
import variables from 'config/variables';
import { memo, useState, useCallback } from 'react';
import { Checkbox as CheckboxUI, FormControlLabel } from '@mui/material';
import { MdCheck } from 'react-icons/md';
import EventBus from 'utils/eventbus';
import './Checkbox.scss';
const Checkbox = memo((props) => {
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
@@ -18,7 +20,7 @@ const Checkbox = memo((props) => {
variables.stats.postEvent(
'setting',
`${props.name} ${checked ? 'enabled' : 'disabled'}`,
`${props.name} ${value ? 'enabled' : 'disabled'}`,
);
if (props.element) {
@@ -31,20 +33,30 @@ const Checkbox = memo((props) => {
EventBus.emit('refresh', props.category);
}, [checked, props]);
const handleKeyDown = useCallback((e) => {
if ((e.key === ' ' || e.key === 'Enter') && !props.disabled) {
e.preventDefault();
handleChange();
}
}, [handleChange, props.disabled]);
return (
<FormControlLabel
control={
<CheckboxUI
name={props.name}
color="primary"
className="checkbox"
checked={checked}
onChange={handleChange}
disabled={props.disabled || false}
/>
}
label={props.text}
/>
<div className={`checkbox-wrapper ${props.disabled ? 'disabled' : ''}`}>
<span className="checkbox-label">{props.text}</span>
<input
type="checkbox"
name={props.name}
checked={checked}
onChange={handleChange}
disabled={props.disabled || false}
className="checkbox-input"
aria-label={props.text}
onKeyDown={handleKeyDown}
/>
<div className={`checkbox-box ${checked ? 'checked' : ''}`}>
{checked && <MdCheck />}
</div>
</div>
);
});

View File

@@ -0,0 +1,118 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(checkScale) {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.checkbox-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
padding: 8px 0;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(.disabled) .checkbox-label {
@include themed {
color: t($link);
}
}
.checkbox-label {
flex: 1;
transition: color 0.2s ease;
pointer-events: none;
@include themed {
color: t($color);
}
}
.checkbox-box {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
transition: all 0.2s ease;
cursor: pointer;
flex-shrink: 0;
pointer-events: none;
@include themed {
border: 2px solid t($modal-sidebarActive);
background: t($modal-sidebar);
color: t($color);
&:hover:not(.disabled) {
border-color: t($color);
transform: scale(1.05);
}
}
&:active:not(.disabled) {
transform: scale(0.95);
@include themed {
box-shadow: 0 0 0 4px rgba(255, 92, 37, 0.1);
}
}
&.checked {
@include themed {
background: t($link);
border-color: t($link);
}
svg {
@include animation(checkScale 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55));
}
}
svg {
font-size: 18px;
color: white;
}
}
.checkbox-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
&:focus-visible + .checkbox-box {
@include themed {
box-shadow: 0 0 0 3px t($link);
}
}
&:disabled {
cursor: not-allowed;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { compressAccurately, filetoDataURL } from 'image-conversion';
import videoCheck from 'features/background/api/videoCheck';
const FileUpload = memo(({ id, type, accept, loadFunction }) => {
const FileUpload = memo(({ id, type, accept, loadFunction, multiple }) => {
useEffect(() => {
const fileInput = document.getElementById(id);
if (!fileInput) return;
@@ -20,40 +20,48 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
return loadFunction(e.target.result);
};
} else {
// background upload - handle multiple files
const settings = {};
// Pass files directly to loadFunction if it's a newer implementation
if (typeof loadFunction === 'function' && loadFunction.length === 1) {
loadFunction(files);
} else {
// Legacy background upload - handle multiple files
const settings = {};
Object.keys(localStorage).forEach((key) => {
settings[key] = localStorage.getItem(key);
});
const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length;
// Process each file
files.forEach((file, index) => {
if (videoCheck(file.type) === true) {
if (settingsSize + file.size > 4850000) {
return toast(variables.getMessage('toasts.no_storage'));
}
return loadFunction(file, index);
}
compressAccurately(file, {
size: 450,
accuracy: 0.9,
}).then(async (res) => {
if (settingsSize + res.size > 4850000) {
return toast(variables.getMessage('toasts.no_storage'));
}
loadFunction({
target: {
result: await filetoDataURL(res),
},
}, index);
Object.keys(localStorage).forEach((key) => {
settings[key] = localStorage.getItem(key);
});
});
const settingsSize = new TextEncoder().encode(JSON.stringify(settings)).length;
// Process each file
files.forEach((file, index) => {
if (videoCheck(file.type) === true) {
if (settingsSize + file.size > 4850000) {
return toast(variables.getMessage('toasts.no_storage'));
}
return loadFunction(file, index);
}
compressAccurately(file, {
size: 450,
accuracy: 0.9,
}).then(async (res) => {
if (settingsSize + res.size > 4850000) {
return toast(variables.getMessage('toasts.no_storage'));
}
loadFunction(
{
target: {
result: await filetoDataURL(res),
},
},
index,
);
});
});
}
}
};
@@ -64,7 +72,7 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
fileInput.onchange = null;
}
};
}, [id, type, loadFunction]);
}, [id, type, loadFunction, multiple]);
return (
<input
@@ -72,7 +80,7 @@ const FileUpload = memo(({ id, type, accept, loadFunction }) => {
type="file"
style={{ display: 'none' }}
accept={accept}
multiple={type !== 'settings'}
multiple={multiple !== undefined ? multiple : type !== 'settings'}
/>
);
});

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
import { memo } from 'react';
import { MdSearch } from 'react-icons/md';
import './SearchInput.scss';
const SearchInput = memo(({ value, onChange, placeholder, fullWidth }) => {
return (
<div className={`search-input-container${fullWidth ? ' full-width' : ''}`}>
<MdSearch className="search-input-icon" />
<input
type="text"
className="search-input-field"
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</div>
);
});
SearchInput.displayName = 'SearchInput';
export { SearchInput as default, SearchInput };

View File

@@ -0,0 +1,48 @@
@use 'scss/variables' as *;
.search-input-container {
position: relative;
display: flex;
align-items: center;
width: 250px;
&.full-width {
width: 100%;
}
.search-input-icon {
position: absolute;
left: 16px;
font-size: 20px;
pointer-events: none;
@include themed {
color: t($subColor);
}
}
.search-input-field {
width: 100%;
height: 48px;
padding: 0 16px 0 44px;
font-size: 15px;
outline: none;
transition: 0.2s ease;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: 24px;
color: t($color);
&:hover,
&:focus {
border-color: t($color);
}
&::placeholder {
color: t($subColor);
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import variables from 'config/variables';
import { memo, useState, useCallback } from 'react';
import { Switch as SwitchUI, FormControlLabel } from '@mui/material';
import EventBus from 'utils/eventbus';
import './Switch.scss';
const Switch = memo((props) => {
const [checked, setChecked] = useState(localStorage.getItem(props.name) === 'true');
@@ -32,18 +33,20 @@ const Switch = memo((props) => {
}, [checked, props]);
return (
<FormControlLabel
control={
<SwitchUI
name={props.name}
color="primary"
checked={checked}
onChange={handleChange}
/>
}
label={props.header ? '' : props.text}
labelPlacement="start"
/>
<div className="switch-wrapper">
{!props.header && <span className="switch-label">{props.text}</span>}
<div className={`switch-track ${checked ? 'checked' : ''}`} onClick={handleChange}>
<div className="switch-thumb" />
</div>
<input
type="checkbox"
name={props.name}
checked={checked}
onChange={handleChange}
className="switch-input"
aria-hidden="true"
/>
</div>
);
});

View File

@@ -0,0 +1,63 @@
@use 'scss/variables' as *;
.switch-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: pointer;
padding: 8px 0;
.switch-label {
flex: 1;
@include themed {
color: t($color);
}
}
.switch-track {
position: relative;
width: 52px;
height: 32px;
border-radius: 16px;
cursor: pointer;
transition: 0.2s ease;
flex-shrink: 0;
@include themed {
background: t($modal-sidebarActive);
}
&.checked {
@include themed {
background: t($link);
}
.switch-thumb {
transform: translateX(20px);
}
}
}
.switch-thumb {
position: absolute;
top: 4px;
left: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
transition: 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
@include themed {
background: t($color);
}
}
.switch-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
}

View File

@@ -1,79 +1,95 @@
import variables from 'config/variables';
import { memo, useState, useCallback } from 'react';
import { toast } from 'react-toastify';
import { TextField } from '@mui/material';
import { MdRefresh } from 'react-icons/md';
import EventBus from 'utils/eventbus';
import './Text.scss';
const Text = memo((props) => {
const [value, setValue] = useState(localStorage.getItem(props.name) || '');
const { name, upperCaseFirst, element, category, onChange, title, textarea, customcss, placeholder } = props;
const defaultValue = props.default;
const [value, setValue] = useState(localStorage.getItem(name) || '');
const handleChange = useCallback((e) => {
let { value } = e.target;
const handleChange = useCallback(
(e) => {
let newValue = e.target.value;
// Alex wanted font to work with montserrat and Montserrat, so I made it work
if (props.upperCaseFirst === true) {
value = value.charAt(0).toUpperCase() + value.slice(1);
}
localStorage.setItem(props.name, value);
setValue(value);
// Call parent onChange if provided
if (props.onChange) {
props.onChange(value);
}
if (props.element) {
if (!document.querySelector(props.element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
if (upperCaseFirst === true) {
newValue = newValue.charAt(0).toUpperCase() + newValue.slice(1);
}
}
EventBus.emit('refresh', props.category);
}, [props.name, props.upperCaseFirst, props.element, props.category, props.onChange]);
localStorage.setItem(name, newValue);
setValue(newValue);
if (onChange) {
onChange(newValue);
}
if (element) {
if (!document.querySelector(element)) {
document.querySelector('.reminder-info').style.display = 'flex';
return localStorage.setItem('showReminder', true);
}
}
EventBus.emit('refresh', category);
},
[name, upperCaseFirst, element, category, onChange],
);
const resetItem = useCallback(() => {
handleChange({
target: {
value: props.default || '',
value: defaultValue || '',
},
});
toast(variables.getMessage('toasts.reset'));
}, [handleChange, props.default]);
}, [handleChange, defaultValue]);
return (
<>
{props.textarea === true ? (
<TextField
label={props.title}
value={value}
onChange={handleChange}
varient="outlined"
className={props.customcss ? 'customcss' : ''}
multiline
spellCheck={false}
minRows={4}
maxRows={10}
InputLabelProps={{ shrink: true }}
/>
<div className="text-field-container">
{textarea === true ? (
<div className={`text-field ${customcss ? 'customcss' : ''}`}>
{title && (
<div className="text-field-header">
<label className="text-field-label">{title}</label>
<span className="text-field-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
)}
<textarea
value={value}
onChange={handleChange}
spellCheck={false}
rows={4}
className="text-field-textarea"
/>
</div>
) : (
<TextField
label={props.title}
value={value}
onChange={handleChange}
varient="outlined"
InputLabelProps={{ shrink: true }}
placeholder={props.placeholder || ''}
/>
<div className="text-field">
{title && (
<div className="text-field-header">
<label className="text-field-label">{title}</label>
<span className="text-field-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
)}
<input
type="text"
value={value}
onChange={handleChange}
placeholder={placeholder || ''}
className="text-field-input"
/>
</div>
)}
<span className="link" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</>
</div>
);
});

View File

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

View File

@@ -0,0 +1,57 @@
import { memo, useRef, useEffect, useCallback } from 'react';
import './Textarea.scss';
const Textarea = memo(({ value, onChange, placeholder, minRows = 1, maxRows, className, style, readOnly }) => {
const textareaRef = useRef(null);
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to auto to get the correct scrollHeight
textarea.style.height = 'auto';
// Calculate line height
const computedStyle = window.getComputedStyle(textarea);
const lineHeight = parseInt(computedStyle.lineHeight) || 24;
const paddingTop = parseInt(computedStyle.paddingTop) || 0;
const paddingBottom = parseInt(computedStyle.paddingBottom) || 0;
// Calculate min and max heights
const minHeight = (minRows * lineHeight) + paddingTop + paddingBottom;
const maxHeight = maxRows ? (maxRows * lineHeight) + paddingTop + paddingBottom : Infinity;
// Set the height based on content, clamped between min and max
const newHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
textarea.style.height = `${newHeight}px`;
}, [minRows, maxRows]);
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
// Adjust on mount and window resize
useEffect(() => {
adjustHeight();
window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight);
}, [adjustHeight]);
return (
<textarea
ref={textareaRef}
className={`textarea-autosize${className ? ` ${className}` : ''}`}
value={value}
onChange={onChange}
placeholder={placeholder}
style={style}
readOnly={readOnly}
rows={minRows}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea as default, Textarea };

View File

@@ -0,0 +1,34 @@
@use 'scss/variables' as *;
.textarea-autosize {
width: 100%;
padding: 12px 16px;
font-size: 15px;
line-height: 24px;
outline: none;
resize: none;
overflow: hidden;
transition: 0.2s ease;
font-family: inherit;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover,
&:focus {
border-color: t($color);
}
&::placeholder {
color: t($subColor);
}
}
&[readonly] {
opacity: 0.6;
cursor: not-allowed;
}
}

View File

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

View File

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

View File

@@ -25,4 +25,4 @@ export const EMAIL = 'hello@muetab.com';
export const TWITTER_HANDLE = 'getmue';
export const DISCORD_SERVER = 'zv8C9F8';
export const VERSION = '7.5.0';
export const VERSION = '7.6.0';

View File

@@ -3,7 +3,7 @@ import { supportsAVIF } from './avifSupport';
import { getOfflineImage } from './offlineImage';
import { randomColourStyleBuilder } from './randomColour';
import videoCheck from './videoCheck';
import { getAllBackgrounds } from 'utils/customBackgroundDB';
import { getAllBackgrounds, getAllBackgroundsWithMetadata } from 'utils/customBackgroundDB';
const parseJSON = (key, fallback = null) => {
const item = localStorage.getItem(key);
@@ -172,12 +172,16 @@ async function getAPIBackground(isOffline) {
* Gets custom background
*/
async function getCustomBackground(isOffline) {
// Try to get from IndexedDB first
let backgrounds = await getAllBackgrounds();
// Get full metadata from IndexedDB
let backgrounds = await getAllBackgroundsWithMetadata();
// Fallback to localStorage if IndexedDB is empty
// Fallback to localStorage URLs if IndexedDB is empty
if (!backgrounds || backgrounds.length === 0) {
backgrounds = parseJSON('customBackground', []);
const urls = parseJSON('customBackground', []);
if (urls && urls.length > 0) {
// Convert old URL format to metadata format
backgrounds = urls.map((url) => ({ url, photoInfo: { hidden: true } }));
}
}
if (!backgrounds || backgrounds.length === 0) return null;
@@ -187,23 +191,33 @@ async function getCustomBackground(isOffline) {
// Check if selected is valid before using it
if (!selected) return null;
if (isOffline && !selected.startsWith('data:')) return getOfflineImage('custom');
const url = selected.url || selected;
if (isOffline && !url.startsWith('data:')) {
return getOfflineImage('custom');
}
const data = {
url: selected,
url,
type: 'custom',
video: videoCheck(selected),
photoInfo: { hidden: true },
video: videoCheck(url),
photoInfo: {
hidden: true,
blur_hash: selected.blurHash || null,
},
};
// Don't store full image data in localStorage to avoid quota errors
// Just store metadata
try {
localStorage.setItem('currentBackground', JSON.stringify({
type: 'custom',
video: data.video,
photoInfo: data.photoInfo,
}));
localStorage.setItem(
'currentBackground',
JSON.stringify({
type: 'custom',
video: data.video,
photoInfo: data.photoInfo,
}),
);
} catch (e) {
// Ignore quota errors for currentBackground
console.warn('Could not save currentBackground to localStorage:', e);

View File

@@ -1,23 +1,37 @@
import { memo } from 'react';
import { memo, useState, useEffect } from 'react';
import PhotoInformation from './PhotoInformation';
import variables from 'config/variables';
import { updateHash } from 'utils/deepLinking';
import EventBus from 'utils/eventbus';
import { getAllBackgrounds } from 'utils/customBackgroundDB';
/**
* BackgroundImage component for rendering image backgrounds
*/
function BackgroundImage({ photoInfo, currentAPI, url }) {
const isCustomType = localStorage.getItem('backgroundType') === 'custom';
const customBackgrounds = (() => {
try {
const stored = localStorage.getItem('customBackground');
return stored && stored !== 'null' ? JSON.parse(stored) : [];
} catch {
return [];
}
})();
const hasNoCustomImages = isCustomType && (!customBackgrounds || customBackgrounds.length === 0);
const [customBackgrounds, setCustomBackgrounds] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadCustomBackgrounds = async () => {
if (isCustomType) {
try {
const backgrounds = await getAllBackgrounds();
setCustomBackgrounds(backgrounds || []);
} catch (error) {
console.error('Failed to load custom backgrounds:', error);
setCustomBackgrounds([]);
}
}
setIsLoading(false);
};
loadCustomBackgrounds();
}, [isCustomType]);
const hasNoCustomImages =
isCustomType && !isLoading && (!customBackgrounds || customBackgrounds.length === 0);
const handleOpenSettings = () => {
updateHash('#settings/background/source');
@@ -28,21 +42,24 @@ function BackgroundImage({ photoInfo, currentAPI, url }) {
<>
<div id="backgroundImage" />
{hasNoCustomImages && (
<div style={{
position: 'absolute',
bottom: '20px',
left: '20px',
color: 'white',
background: 'rgba(0, 0, 0, 0.6)',
padding: '20px 30px',
borderRadius: '10px',
zIndex: 1,
}}>
<div
style={{
position: 'absolute',
bottom: '20px',
left: '20px',
color: 'white',
background: 'rgba(0, 0, 0, 0.6)',
padding: '20px 30px',
borderRadius: '10px',
zIndex: 1,
}}
>
<h2 style={{ margin: '0 0 10px 0', fontSize: '20px' }}>
{variables.getMessage('widgets.background.no_images_title') || 'No Custom Images'}
</h2>
<p style={{ margin: '0 0 15px 0', fontSize: '14px', opacity: 0.9 }}>
{variables.getMessage('widgets.background.no_images_description') || 'Please add custom images in the Background settings'}
{variables.getMessage('widgets.background.no_images_description') ||
'Please add custom images in the Background settings'}
</p>
<button
onClick={handleOpenSettings}
@@ -68,9 +85,7 @@ function BackgroundImage({ photoInfo, currentAPI, url }) {
</button>
</div>
)}
{photoInfo?.credit && (
<PhotoInformation info={photoInfo} api={currentAPI} url={url} />
)}
{photoInfo?.credit && <PhotoInformation info={photoInfo} api={currentAPI} url={url} />}
</>
);
}

View File

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

View File

@@ -0,0 +1,65 @@
import { useState } from 'react';
import variables from 'config/variables';
import { MdClose } from 'react-icons/md';
import { Button } from 'components/Elements';
const FolderTaggingModal = ({ files, onConfirm, onCancel }) => {
const [folderName, setFolderName] = useState('');
const handleConfirm = () => {
onConfirm(folderName.trim());
};
return (
<div className="smallModal">
<div className="shareHeader">
<span className="title">
{variables.getMessage('modals.main.settings.sections.background.source.tag_images')}
</span>
<button className="closeModal" onClick={onCancel}>
<MdClose />
</button>
</div>
<div className="taggingModalContent">
<p className="subtitle">
{variables.getMessage('modals.main.settings.sections.background.source.tag_description', {
count: files.length,
})}
</p>
<div className="taggingInput">
<label>
{variables.getMessage('modals.main.settings.sections.background.source.folder_name')}
</label>
<input
type="text"
placeholder={variables.getMessage(
'modals.main.settings.sections.background.source.folder_placeholder',
)}
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleConfirm();
}
}}
autoFocus
/>
</div>
</div>
<div className="resetFooter">
<Button
type="settings"
onClick={onCancel}
label={variables.getMessage('modals.main.settings.buttons.cancel')}
/>
<Button
type="settings"
onClick={handleConfirm}
label={variables.getMessage('modals.main.settings.buttons.continue')}
/>
</div>
</div>
);
};
export default FolderTaggingModal;

View File

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

View File

@@ -1,9 +1,13 @@
import variables from 'config/variables';
import React, { memo, useState, useMemo } from 'react';
import { MdAutoFixHigh, MdOutlineArrowForward, MdOutlineOpenInNew, MdCheckCircle } from 'react-icons/md';
import {
MdCheckCircle,
MdOutlineUploadFile,
MdClose,
} from 'react-icons/md';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
import { Button } from 'components/Elements';
import { Tooltip } from 'components/Elements';
import Dropdown from '../../../../components/Form/Settings/Dropdown/Dropdown';
function filterItems(item, filter, categoryFilter) {
@@ -28,73 +32,86 @@ function filterItems(item, filter, categoryFilter) {
return textMatch && item.type === categoryMap[categoryFilter];
}
function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInstalled }) {
function getInitials(name) {
if (!name) return '??';
const words = name.split(' ');
if (words.length === 1) {
return name.substring(0, 2).toUpperCase();
}
return words
.slice(0, 2)
.map((word) => word[0])
.join('')
.toUpperCase();
}
function getTypeTranslationKey(type) {
const typeMap = {
photos: 'photo_packs',
quotes: 'quote_packs',
settings: 'preset_settings',
};
return typeMap[type] || type;
}
function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInstalled, isAdded, onUninstall }) {
item._onCollection = onCollection;
// Convert hex color to RGB for gradient with opacity
const hexToRgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
};
const getGradientStyle = () => {
if (!item.colour) return {};
const rgb = hexToRgb(item.colour);
if (!rgb) return {};
const baseColor = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
return {
'--item-gradient0': `rgba(${baseColor}, 0.38)`,
'--item-gradient10': `rgba(${baseColor}, 0.35)`,
'--item-gradient75': `rgba(${baseColor}, 0.14)`,
'--item-gradient100': `rgba(${baseColor}, 0.06)`,
backgroundImage: `radial-gradient(circle at center 25%, var(--item-gradient0) 0%, var(--item-gradient10) 10%, var(--item-gradient75) 75%, var(--item-gradient100) 100%)`,
};
};
const getBadgeStyle = () => {
if (!item.colour) return {};
const rgb = hexToRgb(item.colour);
if (!rgb) return {};
const baseColor = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
return {
backgroundColor: `rgba(${baseColor}, 0.9)`,
};
};
const isSideloaded = item.sideload === true;
return (
<div
className="item"
onClick={() => toggleFunction(item)}
className={`item ${isSideloaded ? 'item-sideloaded' : ''}`}
onClick={isSideloaded ? undefined : () => toggleFunction(item)}
key={item.name}
style={getGradientStyle()}
>
{isInstalled && item.colour && (
<div className="item-installed-badge" style={getBadgeStyle()}>
{isAdded && onUninstall && (
<Tooltip
title={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
style={{ position: 'absolute', top: '12px', right: '12px', zIndex: 3 }}
>
<button
className="item-uninstall-btn"
onClick={(e) => {
e.stopPropagation();
onUninstall(item.type, item.name);
}}
>
<MdClose />
</button>
</Tooltip>
)}
{isSideloaded && (
<Tooltip
title={variables.getMessage('modals.main.addons.sideload.title')}
style={{ position: 'absolute', top: '12px', right: '48px', zIndex: 2 }}
>
<div className="item-sideload-badge">
<MdOutlineUploadFile />
</div>
</Tooltip>
)}
{isInstalled && item.colour && !isSideloaded && (
<div className="item-installed-badge">
<MdCheckCircle />
</div>
)}
<img
className="item-icon"
alt="icon"
draggable={false}
src={item.icon_url}
onError={(e) => {
e.target.onerror = null;
e.target.src = placeholderIcon;
}}
/>
{item.icon_url ? (
<img
className="item-icon"
alt="icon"
draggable={false}
src={item.icon_url}
onError={(e) => {
e.target.onerror = null;
e.target.src = placeholderIcon;
}}
/>
) : (
<div className="item-icon item-icon-text">
{getInitials(item.display_name || item.name)}
</div>
)}
<div className="card-details">
<span className="card-title">{item.display_name || item.name}</span>
{!isCurator ? (
@@ -106,17 +123,14 @@ function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInsta
)}
<div className="card-chips">
{type === 'all' && !onCollection ? (
{item.type && (
<span className="card-type">
{variables.getMessage('modals.main.marketplace.' + item.type)}
{variables.getMessage('modals.main.marketplace.' + getTypeTranslationKey(item.type))}
</span>
) : null}
{/* {item.in_collections && item.in_collections.length > 0 && !onCollection ? (
<span className="card-collection">
{item.in_collections[0]}
</span>
) : null} */}
)}
{item.in_collections && item.in_collections.length > 0 && !onCollection && (
<span className="card-collection">{item.in_collections[0]}</span>
)}
</div>
</div>
</div>
@@ -127,15 +141,13 @@ function Items({
isCurator,
type,
items,
collection,
toggleFunction,
collectionFunction,
onCollection,
filter,
moreByCreator,
showCreateYourOwn,
filterOptions = false,
onSortChange,
isAdded = false,
onUninstall,
}) {
const [selectedCategory, setSelectedCategory] = useState('all');
const [sortType, setSortType] = useState(localStorage.getItem('sortMarketplace') || 'a-z');
@@ -161,48 +173,8 @@ function Items({
}
};
const shouldShowCollection =
((collection && !onCollection && (filter === null || filter === '')) ||
(type === 'collections' && !onCollection && (filter === null || filter === ''))) &&
type !== 'preset_settings';
return (
<>
{shouldShowCollection && (
<div
className="collection"
style={
collection?.news
? { backgroundColor: collection?.background_colour }
: {
backgroundImage: `linear-gradient(to right, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0.7), transparent, rgba(0, 0, 0, 0.7), rgba(0 ,0, 0, 0.9)), url('${collection?.img}')`,
}
}
>
<div className="content">
<span className="title">{collection?.display_name}</span>
<span className="subtitle">{collection?.description}</span>
</div>
{collection?.news === true ? (
<a
className="btn-collection"
href={collection?.news_link}
target="_blank"
rel="noopener noreferrer"
>
{variables.getMessage('modals.main.marketplace.learn_more')} <MdOutlineOpenInNew />
</a>
) : (
<Button
type="collection"
onClick={() => collectionFunction(collection?.name)}
icon={<MdOutlineArrowForward />}
label={variables.getMessage('modals.main.marketplace.explore_collection')}
iconPlacement={'right'}
/>
)}
</div>
)}
{/* Items Filter Options */}
{filterOptions && (
<div className="filter-options-container">
@@ -228,7 +200,7 @@ function Items({
/>
</div>
)}
<div className={`items ${moreByCreator ? 'creatorItems' : ''}`}>
<div className='items'>
{items
?.filter((item) => filterItems(item, filter, filterOptions ? selectedCategory : 'all'))
.map((item, index) => (
@@ -239,29 +211,13 @@ function Items({
type={type}
onCollection={onCollection}
isInstalled={installedNames.has(item.name)}
isAdded={isAdded}
onUninstall={onUninstall}
key={index}
/>
))}
</div>
<div className="loader"></div>
{!onCollection && showCreateYourOwn ? (
<div className="createYourOwn">
<MdAutoFixHigh />
<span className="title">{variables.getMessage('modals.main.marketplace.cant_find')}</span>
<span className="subtitle">
{variables.getMessage('modals.main.marketplace.knowledgebase_one') + ' '}
<a
className="link"
target="_blank"
href={variables.constants.KNOWLEDGEBASE}
rel="noreferrer"
>
{variables.getMessage('modals.main.marketplace.knowledgebase_two')}
</a>
{' ' + variables.getMessage('modals.main.marketplace.knowledgebase_three')}
</span>
</div>
) : null}
</>
);
}

View File

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

View File

@@ -2,9 +2,9 @@ import variables from 'config/variables';
import { useState } from 'react';
import { MdCancel, MdAdd, MdOutlineTextsms } from 'react-icons/md';
import { toast } from 'react-toastify';
import { TextareaAutosize } from '@mui/material';
import { Header, Row, Content, Action, PreferencesWrapper } from 'components/Layout/Settings';
import { Textarea } from 'components/Form/Settings';
import { Button } from 'components/Elements';
import EventBus from 'utils/eventbus';
@@ -82,14 +82,13 @@ const MessageOptions = () => {
<span className="subtitle">
{variables.getMessage(`${MESSAGE_SECTION}.title`)}
</span>
<TextareaAutosize
<Textarea
value={messages[index]}
placeholder={variables.getMessage(
'modals.main.settings.sections.message.content',
)}
onChange={(e) => message(e, true, index)}
varient="outlined"
style={{ padding: '0' }}
minRows={2}
/>
</div>
</div>

View File

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

View File

@@ -2,7 +2,6 @@ import variables from 'config/variables';
import { useState, memo } from 'react';
import { Checkbox, Slider } from 'components/Form/Settings';
import { Button } from 'components/Elements';
import { TextField } from '@mui/material';
import { toast } from 'react-toastify';
import EventBus from 'utils/eventbus';
@@ -39,22 +38,26 @@ function ExperimentalOptions() {
element=".other"
/>
<p style={{ textAlign: 'left', width: '100%' }}>Send Event</p>
<TextField
label={'Type'}
value={eventType}
onChange={(e) => setEventType(e.target.value)}
spellCheck={false}
varient="outlined"
InputLabelProps={{ shrink: true }}
/>
<TextField
label={'Name'}
value={eventName}
onChange={(e) => setEventName(e.target.value)}
spellCheck={false}
varient="outlined"
InputLabelProps={{ shrink: true }}
/>
<div className="text-field">
<label className="text-field-label">Type</label>
<input
type="text"
className="text-field-input"
value={eventType || ''}
onChange={(e) => setEventType(e.target.value)}
spellCheck={false}
/>
</div>
<div className="text-field">
<label className="text-field-label">Name</label>
<input
type="text"
className="text-field-input"
value={eventName || ''}
onChange={(e) => setEventName(e.target.value)}
spellCheck={false}
/>
</div>
<Button
type="settings"
onClick={() => EventBus.emit(eventType, eventName)}

View File

@@ -1,10 +1,9 @@
import { useState, useMemo } from 'react';
import { useT, useTranslation } from 'contexts/TranslationContext';
import { MdOutlineOpenInNew, MdSearch, MdComputer } from 'react-icons/md';
import { TextField, InputAdornment } from '@mui/material';
import { MdOutlineOpenInNew, MdComputer } from 'react-icons/md';
import { Radio, Checkbox } from 'components/Form/Settings';
import { Radio, Checkbox, SearchInput } from 'components/Form/Settings';
import languages from '@/i18n/languages.json';
import translationPercentages from '@/i18n/translationPercentages.json';
@@ -123,35 +122,10 @@ const LanguageOptions = () => {
marginBottom: 16,
}}
>
<TextField
<SearchInput
placeholder={t('modals.main.settings.sections.language.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
variant="outlined"
size="small"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MdSearch style={{ color: '#888' }} />
</InputAdornment>
),
}}
sx={{
width: '250px',
'& .MuiOutlinedInput-root': {
borderRadius: '24px',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
'& fieldset': {
border: 'none',
},
'&:hover fieldset': {
border: 'none',
},
'&.Mui-focused fieldset': {
border: 'none',
},
},
}}
/>
{currentLangOption && (
<div style={{ color: '#888', whiteSpace: 'nowrap' }}>

View File

@@ -4,9 +4,9 @@ import { useT } from 'contexts';
import { MdContentCopy, MdAssignment, MdPushPin, MdDownload } from 'react-icons/md';
import { useFloating, shift } from '@floating-ui/react-dom';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import { toast } from 'react-toastify';
import { Tooltip } from 'components/Elements';
import { Textarea } from 'components/Form/Settings';
import { saveFile } from 'utils/saveFile';
import EventBus from 'utils/eventbus';
@@ -112,12 +112,11 @@ const Notes = ({ notesRef, floatRef, position, xPosition, yPosition }) => {
</button>
</Tooltip>
</div>
<TextareaAutosize
<Textarea
placeholder={t('widgets.navbar.notes.placeholder')}
value={notes}
onChange={handleSetNotes}
minRows={5}
maxLength={10000}
/>
</div>
</span>

View File

@@ -9,11 +9,10 @@ import {
MdPlaylistAdd,
MdOutlineDragIndicator,
MdPlaylistRemove,
MdCheck,
} from 'react-icons/md';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import { Tooltip } from 'components/Elements';
import Checkbox from '@mui/material/Checkbox';
import { Textarea } from 'components/Form/Settings';
import { shift, useFloating } from '@floating-ui/react-dom';
import {
DndContext,
@@ -210,15 +209,18 @@ function Todo({ todoRef, floatRef, position, xPosition, yPosition }) {
<SortableItem key={index} id={index}>
{({ attributes, listeners }) => (
<div className={'todoRow' + (todoItem.done ? ' done' : '')}>
<Checkbox
checked={todoItem.done}
<div
className={'todo-checkbox' + (todoItem.done ? ' checked' : '')}
onClick={() => updateTodo('done', index)}
/>
<TextareaAutosize
>
{todoItem.done && <MdCheck />}
</div>
<Textarea
placeholder={t('widgets.navbar.notes.placeholder')}
value={todoItem.value}
onChange={(data) => updateTodo('set', index, data)}
readOnly={todoItem.done}
minRows={1}
/>
<Tooltip
title={t(

View File

@@ -288,7 +288,8 @@ button.quicklinks {
flex-flow: column;
align-items: center;
min-width: 100px;
background-image: linear-gradient(to left, rgb(0 0 0), transparent, rgb(0 0 0)),
background-image:
linear-gradient(to left, rgb(0 0 0), transparent, rgb(0 0 0)),
url('https://media.cntraveller.com/photos/615ee85…/16:9/w_2580,c_limit/Best%20Cities%20in%20the%20World%20-%20Grid.jpg');
transition: 0.8s;
text-align: left;
@@ -605,7 +606,7 @@ button.quicklinks {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
padding-bottom: 50px;
}
.quicklink-wrapper .quicklinkstext {

View File

@@ -1,58 +1,60 @@
import { useState, useEffect } from 'react';
import { MdContentCopy, MdStarBorder, MdStar, MdIosShare } from 'react-icons/md';
import { Tooltip } from 'components/Elements';
import { useT } from 'contexts';
import variables from 'config/variables';
import EventBus from 'utils/eventbus';
/**
* Quote action buttons component
*/
export default function QuoteButtons({
onCopy,
onFavourite,
onShare,
isFavourited,
}) {
export default function QuoteButtons({ onCopy, onFavourite, onShare, isFavourited }) {
const t = useT();
const showCopy = localStorage.getItem('copyButton') !== 'false';
const showShare = localStorage.getItem('quoteShareButton') !== 'false';
const showFavourite = localStorage.getItem('favouriteQuoteEnabled') === 'true';
const [showCopy, setShowCopy] = useState(localStorage.getItem('copyButton') !== 'false');
const [showShare, setShowShare] = useState(localStorage.getItem('quoteShareButton') !== 'false');
const [showFavourite, setShowFavourite] = useState(
localStorage.getItem('favouriteQuoteEnabled') === 'true',
);
useEffect(() => {
const handleRefresh = (data) => {
if (data === 'quote') {
setShowCopy(localStorage.getItem('copyButton') !== 'false');
setShowShare(localStorage.getItem('quoteShareButton') !== 'false');
setShowFavourite(localStorage.getItem('favouriteQuoteEnabled') === 'true');
}
};
EventBus.on('refresh', handleRefresh);
return () => {
EventBus.off('refresh', handleRefresh);
};
}, []);
return (
<>
{showCopy && (
<Tooltip title={t('widgets.quote.copy')}>
<button
onClick={onCopy}
aria-label={t('widgets.quote.copy')}
>
<button onClick={onCopy} aria-label={t('widgets.quote.copy')}>
<MdContentCopy className="copyButton" />
</button>
</Tooltip>
)}
{showShare && (
<Tooltip title={t('widgets.quote.share')}>
<button
onClick={onShare}
aria-label={t('widgets.quote.share')}
>
<button onClick={onShare} aria-label={t('widgets.quote.share')}>
<MdIosShare className="copyButton" />
</button>
</Tooltip>
)}
{showFavourite && (
<Tooltip
title={
isFavourited
? t('widgets.quote.unfavourite')
: t('widgets.quote.favourite')
}
title={isFavourited ? t('widgets.quote.unfavourite') : t('widgets.quote.favourite')}
>
<button
onClick={onFavourite}
aria-label={
isFavourited
? t('widgets.quote.unfavourite')
: t('widgets.quote.favourite')
isFavourited ? t('widgets.quote.unfavourite') : t('widgets.quote.favourite')
}
>
{isFavourited ? (

View File

@@ -83,7 +83,13 @@ export function useQuoteLoader(updateQuote) {
const getQuote = useCallback(async () => {
const offline = localStorage.getItem('offlineMode') === 'true';
const type = localStorage.getItem('quoteType') || 'api';
let type = localStorage.getItem('quoteType') || 'quote_pack';
// Migrate deprecated 'api' type to 'quote_pack'
if (type === 'api') {
type = 'quote_pack';
localStorage.setItem('quoteType', 'quote_pack');
}
// Check for favourite quote first
const favouriteQuote = localStorage.getItem('favouriteQuote');
@@ -128,7 +134,8 @@ export function useQuoteLoader(updateQuote) {
});
}
case 'quote_pack': {
case 'quote_pack':
default: {
if (offline) return doOffline();
const installed = JSON.parse(localStorage.getItem('installed') || '[]');
@@ -138,56 +145,31 @@ export function useQuoteLoader(updateQuote) {
...quote,
fallbackauthorimg: item.icon_url,
packName: item.display_name || item.name,
noAuthorImg: item.noAuthorImg || quote.noAuthorImg,
})));
if (quotePack.length === 0) return doOffline();
const data = quotePack[Math.floor(Math.random() * quotePack.length)];
const hasAuthor = data.author && data.author.trim() !== '';
const displayAuthor = hasAuthor ? data.author : data.packName;
// Try to get author image from Wikipedia unless pack disables it
let authorimgdata = { authorimg: data.fallbackauthorimg, authorimglicense: null };
if (hasAuthor && !data.noAuthorImg) {
const wikiImg = await getAuthorImg(data.author);
if (wikiImg.authorimg) {
authorimgdata = wikiImg;
}
}
return updateQuote({
quote: `"${data.quote}"`,
author: hasAuthor ? data.author : data.packName,
author: displayAuthor,
authorlink: hasAuthor ? getAuthorLink(data.author) : null,
authorimg: data.fallbackauthorimg,
...authorimgdata,
});
}
case 'api': {
if (offline) return doOffline();
const fetchAPIQuote = async () => {
const response = await fetch(
`${variables.constants.API_URL}/quotes/random`
).then(res => res.json());
if (response.statusCode === 429) return null;
const authorimgdata = await getAuthorImg(response.author);
return {
quote: `"${response.quote.replace(/\s+$/g, '')}"`,
author: response.author,
authorlink: getAuthorLink(response.author),
...authorimgdata,
authorOccupation: response.author_occupation,
};
};
try {
const data = JSON.parse(localStorage.getItem('nextQuote')) || await fetchAPIQuote();
localStorage.setItem('nextQuote', null);
if (data) {
updateQuote(data);
localStorage.setItem('currentQuote', JSON.stringify(data));
localStorage.setItem('nextQuote', JSON.stringify(await fetchAPIQuote()));
} else {
doOffline();
}
} catch {
doOffline();
}
break;
}
}
}, [updateQuote, getAuthorLink, getAuthorImg, doOffline]);

View File

@@ -1,7 +1,6 @@
import variables from 'config/variables';
import React, { useState } from 'react';
import { MdCancel, MdAdd, MdSource, MdOutlineFormatQuote } from 'react-icons/md';
import TextareaAutosize from '@mui/material/TextareaAutosize';
import {
Header,
@@ -11,7 +10,7 @@ import {
Section,
PreferencesWrapper,
} from 'components/Layout/Settings';
import { Checkbox, Dropdown } from 'components/Form/Settings';
import { Checkbox, Dropdown, Textarea } from 'components/Form/Settings';
import { Button } from 'components/Elements';
const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) => {
@@ -23,7 +22,15 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
return data;
};
const [quoteType, setQuoteType] = useState(localStorage.getItem('quoteType') || 'api');
const [quoteType, setQuoteType] = useState(() => {
let type = localStorage.getItem('quoteType') || 'quote_pack';
// Migrate deprecated 'api' type to 'quote_pack'
if (type === 'api') {
type = 'quote_pack';
localStorage.setItem('quoteType', 'quote_pack');
}
return type;
});
const [customQuote, setCustomQuote] = useState(getCustom());
const handleCustomQuote = (e, text, index, type) => {
@@ -93,10 +100,6 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
value: 'quote_pack',
text: variables.getMessage('modals.main.marketplace.title'),
},
{
value: 'api',
text: variables.getMessage('modals.main.settings.sections.background.type.api'),
},
{ value: 'custom', text: variables.getMessage(`${QUOTE_SECTION}.custom`) },
]}
/>
@@ -162,23 +165,19 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
<MdOutlineFormatQuote />
</div>
<div className="messageText">
<TextareaAutosize
<Textarea
value={customQuote[index].quote}
placeholder={variables.getMessage(
'modals.main.settings.sections.quote.title',
)}
placeholder={variables.getMessage('modals.main.settings.sections.quote.title')}
onChange={(e) => handleCustomQuote(e, true, index, 'quote')}
varient="outlined"
style={{ fontSize: '22px', fontWeight: 'bold' }}
minRows={1}
/>
<TextareaAutosize
<Textarea
value={customQuote[index].author}
placeholder={variables.getMessage(
'modals.main.settings.sections.quote.author',
)}
placeholder={variables.getMessage('modals.main.settings.sections.quote.author')}
className="subtitle"
onChange={(e) => handleCustomQuote(e, true, index, 'author')}
varient="outlined"
minRows={1}
/>
</div>
<div>
@@ -187,9 +186,7 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
type="settings"
onClick={() => modifyCustomQuote('remove', index)}
icon={<MdCancel />}
label={variables.getMessage(
'modals.main.marketplace.product.buttons.remove',
)}
label={variables.getMessage('modals.main.marketplace.product.buttons.remove')}
/>
</div>
</div>

View File

@@ -8,6 +8,13 @@ import EventBus from 'utils/eventbus';
import './clock.scss';
// Helper function to format padded time values while preserving padding
const formatPaddedDigits = (value) => {
const str = String(value);
// Format each digit individually to preserve padding with locale numerals
return str.split('').map(digit => formatDigits(digit)).join('');
};
const Clock = () => {
const [timeType] = useState(localStorage.getItem('timeType'));
const [time, setTime] = useState('');
@@ -51,23 +58,22 @@ const Clock = () => {
if (localStorage.getItem('seconds') === 'true') {
const secs = ('00' + now.getSeconds()).slice(-2);
sec = `:${formatDigits(secs)}`;
setFinalSeconds(formatDigits(secs));
sec = `:${formatPaddedDigits(secs)}`;
setFinalSeconds(formatPaddedDigits(secs));
}
if (localStorage.getItem('timeformat') === 'twentyfourhour') {
if (zero === 'false') {
const hours = now.getHours();
const minutes = ('00' + now.getMinutes()).slice(-2);
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
setFinalHour(formatDigits(hours));
setFinalMinute(formatDigits(minutes));
} else {
const minutes = ('00' + now.getMinutes()).slice(-2);
if (zero === 'true') {
const hours = ('00' + now.getHours()).slice(-2);
const minutes = ('00' + now.getMinutes()).slice(-2);
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
time = `${formatPaddedDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
setFinalHour(formatPaddedDigits(hours));
setFinalMinute(formatPaddedDigits(minutes));
} else {
const hours = now.getHours();
time = `${formatDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
setFinalHour(formatDigits(hours));
setFinalMinute(formatDigits(minutes));
setFinalMinute(formatPaddedDigits(minutes));
}
setTime(time);
@@ -82,17 +88,16 @@ const Clock = () => {
hours = 12;
}
if (zero === 'false') {
const minutes = ('00' + now.getMinutes()).slice(-2);
time = `${formatDigits(hours)}:${formatDigits(minutes)}${sec}`;
setFinalHour(formatDigits(hours));
setFinalMinute(formatDigits(minutes));
} else {
const minutes = ('00' + now.getMinutes()).slice(-2);
if (zero === 'true') {
const paddedHours = ('00' + hours).slice(-2);
const minutes = ('00' + now.getMinutes()).slice(-2);
time = `${formatDigits(paddedHours)}:${formatDigits(minutes)}${sec}`;
setFinalHour(formatDigits(paddedHours));
setFinalMinute(formatDigits(minutes));
time = `${formatPaddedDigits(paddedHours)}:${formatPaddedDigits(minutes)}${sec}`;
setFinalHour(formatPaddedDigits(paddedHours));
setFinalMinute(formatPaddedDigits(minutes));
} else {
time = `${formatDigits(hours)}:${formatPaddedDigits(minutes)}${sec}`;
setFinalHour(formatDigits(hours));
setFinalMinute(formatPaddedDigits(minutes));
}
setTime(time);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Imagens personalizadas",
"custom_description": "Selecione imagens do seu computador",
"remove": "Remover imagem"
"remove": "Remover imagem",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Ecrã",
"display_subtitle": "Alterar como as informações de plano de fundo e foto são carregadas",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Redefinir",
"import": "Importar",
"export": "Exportar"
"export": "Exportar",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Organizar",
"newest": "Instalado (mais recente)",
"oldest": "Instalado (mais antigo)",
"a_z": "Alfabética (A-Z)",
"z_a": "Alfabética (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Criar",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Imagens personalizadas",
"custom_description": "Selecione imagens do seu computador",
"remove": "Remover imagem"
"remove": "Remover imagem",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Tela",
"display_subtitle": "Alterar como as informações de plano de fundo e foto são carregadas",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Redefinir",
"import": "Importar",
"export": "Exportar"
"export": "Exportar",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Organizar",
"newest": "Instalado (mais recente)",
"oldest": "Instalado (mais antigo)",
"a_z": "Alfabética (A-Z)",
"z_a": "Alfabética (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Criar",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Пользовательские изображения",
"custom_description": "Выберите изображения с вашего локального компьютера",
"remove": "Удалить изображение"
"remove": "Удалить изображение",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Отображать",
"display_subtitle": "Изменение способа загрузки фона и информации о фотографии",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Сбросить",
"import": "Импорт",
"export": "Экспорт"
"export": "Экспорт",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Сортировка",
"newest": "Установлено (Новое)",
"oldest": "Установлено (Старое)",
"a_z": "Алфавитный (A-Z)",
"z_a": "Алфавитный (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Создать",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Lastne slike",
"custom_description": "Izberite slike iz svojega računalnika",
"remove": "Odstrani sliko"
"remove": "Odstrani sliko",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Prikaz",
"display_subtitle": "Spremenite, kako se ozadje in informacije o fotografiji nalagajo",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Ponastavi",
"import": "Uvozi",
"export": "Izvozi"
"export": "Izvozi",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Razvrsti",
"newest": "Nameščeno (najnovejše)",
"oldest": "Nameščeno (najstarejše)",
"a_z": "Abecedno (A-Ž)",
"z_a": "Abecedno (Ž-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Ustvari",

View File

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

View File

@@ -267,7 +267,25 @@
},
"custom_title": "தனிப்பயன் படங்கள்",
"custom_description": "உங்கள் உள்ளக கணினியிலிருந்து படங்களைத் தேர்ந்தெடுக்கவும்",
"remove": "படத்தை அகற்று"
"remove": "படத்தை அகற்று",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "காட்சி",
"display_subtitle": "பின்னணி மற்றும் புகைப்படத் தகவல்கள் எவ்வாறு ஏற்றப்படுகின்றன என்பதை மாற்றவும்",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "மீட்டமை",
"import": "இறக்குமதி",
"export": "ஏற்றுமதி"
"export": "ஏற்றுமதி",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "வரிசைப்படுத்து",
"newest": "நிறுவப்பட்டது (புதியது)",
"oldest": "நிறுவப்பட்டது (பழமையானது)",
"a_z": "அகரவரிசை (A-Z)",
"z_a": "அகரவரிசை (z-a)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "உருவாக்கு",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Özel Arka Plan",
"custom_description": "Bilgisayarınızdan görseli seçin",
"remove": "Görseli Kaldır"
"remove": "Görseli Kaldır",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Görüntüleme",
"display_subtitle": "Arka plan ve fotoğraf bilgilerinin nasıl yükleneceğini değiştirin",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Sıfırla",
"import": "İçeri Aktar",
"export": "Dışarı Aktar"
"export": "Dışarı Aktar",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Sırala",
"newest": "Kurulanlar (En Yeni)",
"oldest": "Kurulanlar (En Eski)",
"a_z": "Alfabetik (A-Z)",
"z_a": "Alfabetik (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Oluştur",

View File

@@ -267,7 +267,25 @@
},
"custom_title": "Користувацькі зображення",
"custom_description": "Виберіть зображення з вашего локального комп'ютера",
"remove": "Видалити зображення"
"remove": "Видалити зображення",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "Відображати",
"display_subtitle": "Зміна способу завантаження фону та інформації про фотографію",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "Скинути",
"import": "Імпорт",
"export": "Експорт"
"export": "Експорт",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "Сортування",
"newest": "Встановлені (найновіші)",
"oldest": "Встановлені (найстаріші)",
"a_z": "За алфавітом (A-Z)",
"z_a": "За алфавітом (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "Створити",

View File

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

View File

@@ -267,7 +267,25 @@
},
"custom_title": "自定义图片",
"custom_description": "从你的计算机本地选择图片",
"remove": "删除图片"
"remove": "删除图片",
"delete_selected": "Delete {count} Selected",
"uploading": "Uploading {current} of {total}...",
"images": "images",
"tag_images": "Tag Images",
"tag_description": "Add {count} images to a folder (optional)",
"folder_name": "Folder Name",
"folder_placeholder": "Leave empty for no folder",
"storage_info": "Storage Information",
"storage_description": "Mue uses browser storage to save your custom images. The current limit is approximately 4.85MB for all settings and images combined.",
"storage_current": "Current usage: {used} of {total} ({percent}%)",
"sort": {
"date_newest": "Date Added (Newest)",
"date_oldest": "Date Added (Oldest)",
"name_asc": "Name (A-Z)",
"name_desc": "Name (Z-A)",
"size_small": "File Size (Smallest)",
"size_large": "File Size (Largest)"
}
},
"display": "显示",
"display_subtitle": "Change how background and photo information are loaded",
@@ -518,7 +536,10 @@
"buttons": {
"reset": "重置",
"import": "导入",
"export": "导出"
"export": "导出",
"cancel": "Cancel",
"continue": "Continue",
"close": "Close"
}
},
"marketplace": {
@@ -600,9 +621,8 @@
"sort": {
"title": "排序",
"newest": "安装过 (从新到旧)",
"oldest": "安装过 (从旧到新)",
"a_z": "首字母 (A-Z)",
"z_a": "首字母 (Z-A)"
"recently_updated": "Recently Updated"
},
"create": {
"title": "创建",

View File

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

View File

@@ -1,41 +1,98 @@
@use 'variables' as *;
@use 'mixins' as *;
.Toastify__toast-body {
font-size: 16px !important;
padding: 15px 20px !important;
}
.Toastify__toast {
border-radius: 12px !important;
height: auto !important;
width: auto !important;
min-width: auto !important;
padding: 10px !important;
}
.Toastify__close-button {
align-self: center !important;
color: var(--modal-text) !important;
}
/* .Toastify__progress-bar--default {
height: 3px !important;
background: var(--modal-text) !important;
color: var(--modal-text) !important;
} */
// Toast container
.Toastify__toast-container {
width: auto !important;
padding: 16px !important;
}
.Toastify__toast--default {
@extend %basic;
// Main toast styling with glassmorphism effect
.Toastify__toast {
@include themed() {
background: t($background) !important;
color: t($color) !important;
box-shadow: t($boxShadow), 0 8px 32px rgb(0 0 0 / 20%) !important;
}
border-radius: $borderRadius !important;
-webkit-backdrop-filter: blur(15px) saturate(180%) !important;
backdrop-filter: blur(15px) saturate(180%) !important;
padding: 16px 20px !important;
min-height: 64px !important;
width: auto !important;
min-width: 300px !important;
max-width: 500px !important;
font-family: inherit !important;
box-sizing: border-box !important;
transition: all 0.3s ease !important;
&:hover {
transform: translateY(-2px) !important;
box-shadow: 0 0 0 1px #484848, 0 12px 40px rgb(0 0 0 / 30%) !important;
}
}
.Toastify__toast--info {
@extend %basic;
// Toast body
.Toastify__toast-body {
padding: 0 !important;
font-size: 15px !important;
line-height: 1.5 !important;
margin: 0 !important;
@include themed() {
color: t($color) !important;
}
}
.Toastify__progress-bar--info {
background-color: gold !important;
// Close button
.Toastify__close-button {
@include themed() {
color: t($color) !important;
opacity: 0.6 !important;
}
align-self: center !important;
transition: all 0.2s ease !important;
&:hover {
opacity: 1 !important;
}
}
// Progress bar base styling
.Toastify__progress-bar {
height: 4px !important;
border-radius: 0 0 $borderRadius $borderRadius !important;
&--default,
&--info {
@include themed() {
background: linear-gradient(90deg, t($link) 0%, #dd3b67 100%) !important;
}
}
&--success {
background: linear-gradient(90deg, rgb(46 213 115) 0%, rgb(26 188 156) 100%) !important;
}
&--warning {
background: linear-gradient(90deg, #ffb032 0%, #ff9500 100%) !important;
}
&--error {
background: linear-gradient(90deg, rgb(255 71 87) 0%, #dd3b67 100%) !important;
}
}
// Toast type variants - all use the base glassmorphism style
.Toastify__toast--default,
.Toastify__toast--info,
.Toastify__toast--success,
.Toastify__toast--warning,
.Toastify__toast--error {
@include themed() {
background: t($background) !important;
color: t($color) !important;
}
}

View File

@@ -181,6 +181,7 @@ $theme-map: null;
.subtitle {
font-size: 16px;
font-family: 'Inter', sans-serif !important;
@include themed {
color: t($subColor);

View File

@@ -10,7 +10,7 @@ body {
}
* {
font-family: 'Lexend Deca', Montserrat, sans-serif !important;
font-family: 'Lexend Deca', Inter, sans-serif !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
outline: none;
@@ -91,7 +91,7 @@ body {
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../../node_modules/@fontsource/lexend-deca/files/lexend-deca-latin-400-normal.woff2')
src: url(../../node_modules/@fontsource/lexend-deca/files/lexend-deca-latin-400-normal.woff2)
format('woff2');
/* stylelint-disable-next-line unit-no-unknown */
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F,
@@ -103,7 +103,7 @@ body {
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../../node_modules/@fontsource/lexend-deca/files/lexend-deca-latin-ext-400-normal.woff2')
src: url(../../node_modules/@fontsource/lexend-deca/files/lexend-deca-latin-ext-400-normal.woff2)
format('woff2');
/* stylelint-disable-next-line unit-no-unknown */
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113,
@@ -111,14 +111,53 @@ body {
}
@font-face {
font-family: Montserrat;
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
/* stylelint-disable-next-line unit-no-unknown */
src: url(../../node_modules/@fontsource/inter/files/inter-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
src: url('../../node_modules/@fontsource/montserrat/files/montserrat-cyrillic-400-normal.woff2')
format('woff2');
/* stylelint-disable-next-line unit-no-unknown */
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
src: url(../../node_modules/@fontsource/inter/files/inter-greek-400-normal.woff2) format('woff2');
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
/* stylelint-disable-next-line unit-no-unknown */
src: url(../../node_modules/@fontsource/inter/files/inter-greek-ext-400-normal.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
/* stylelint-disable-next-line unit-no-unknown */
src: url(../../node_modules/@fontsource/inter/files/inter-vietnamese-400-normal.woff2) format('woff2');
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-weight: 400;
/* stylelint-disable-next-line unit-no-unknown */
src: url(../../node_modules/@fontsource/inter/files/inter-cyrillic-400-normal.woff2) format('woff2');
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* error */

View File

@@ -23,7 +23,10 @@ function openDB() {
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
const objectStore = db.createObjectStore(STORE_NAME, {
keyPath: 'id',
autoIncrement: true,
});
objectStore.createIndex('url', 'url', { unique: false });
}
};
@@ -31,10 +34,10 @@ function openDB() {
}
/**
* Get all custom backgrounds
* @returns {Promise<Array<string>>} Array of background URLs
* Get all custom backgrounds as objects
* @returns {Promise<Array<Object>>} Array of background objects with metadata
*/
export async function getAllBackgrounds() {
export async function getAllBackgroundsWithMetadata() {
try {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readonly');
@@ -44,8 +47,26 @@ export async function getAllBackgrounds() {
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const results = request.result;
// Return array of URLs in order
resolve(results.map(item => item.url));
// Return array of background objects in order
// For backward compatibility, convert old string URLs to objects
resolve(
results.map((item) => {
if (typeof item.url === 'string' && !item.name) {
// Old format - migrate to new format
return {
id: item.id,
url: item.url,
name: `Image ${item.id}`,
uploadDate: item.createdAt || Date.now(),
dimensions: null,
fileSize: null,
folder: '',
blurHash: null,
};
}
return item;
}),
);
};
request.onerror = () => reject(request.error);
});
@@ -56,15 +77,38 @@ export async function getAllBackgrounds() {
}
/**
* Add a new background
* @param {string} url - The background URL (data URL or remote URL)
* Get all custom backgrounds (URLs only for backward compatibility)
* @returns {Promise<Array<string>>} Array of background URLs
*/
export async function getAllBackgrounds() {
const backgrounds = await getAllBackgroundsWithMetadata();
return backgrounds.map((bg) => bg.url || bg);
}
/**
* Add a new background with metadata
* @param {Object} backgroundData - The background data object
* @param {string} backgroundData.url - The background URL (data URL or remote URL)
* @param {string} backgroundData.name - The filename
* @param {number} backgroundData.uploadDate - Upload timestamp
* @param {Object} backgroundData.dimensions - {width, height}
* @param {number} backgroundData.fileSize - File size in bytes
* @param {string} backgroundData.folder - Folder name (optional)
* @param {string} backgroundData.blurHash - BlurHash string (optional)
* @returns {Promise<number>} The ID of the added background
*/
export async function addBackground(url) {
export async function addBackground(backgroundData) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.add({ url, createdAt: Date.now() });
// Support old string format for backward compatibility
const data =
typeof backgroundData === 'string'
? { url: backgroundData, name: 'Image', uploadDate: Date.now(), folder: '' }
: { ...backgroundData, uploadDate: backgroundData.uploadDate || Date.now() };
const request = store.add(data);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
@@ -75,10 +119,10 @@ export async function addBackground(url) {
/**
* Update a background at a specific index
* @param {number} index - The index to update (0-based)
* @param {string} url - The new URL
* @param {Object|string} backgroundData - The new background data
* @returns {Promise<void>}
*/
export async function updateBackground(index, url) {
export async function updateBackground(index, backgroundData) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
@@ -91,7 +135,13 @@ export async function updateBackground(index, url) {
const items = getAllRequest.result;
if (items[index]) {
const item = items[index];
item.url = url;
// Support old string format
if (typeof backgroundData === 'string') {
item.url = backgroundData;
} else {
Object.assign(item, backgroundData);
}
item.updatedAt = Date.now();
const updateRequest = store.put(item);
@@ -99,7 +149,7 @@ export async function updateBackground(index, url) {
updateRequest.onerror = () => reject(updateRequest.error);
} else {
// If index doesn't exist, add it
addBackground(url).then(resolve).catch(reject);
addBackground(backgroundData).then(resolve).catch(reject);
}
};
getAllRequest.onerror = () => reject(getAllRequest.error);
@@ -134,6 +184,48 @@ export async function deleteBackground(index) {
});
}
/**
* Delete multiple backgrounds by indices
* @param {Array<number>} indices - Array of indices to delete (0-based)
* @returns {Promise<void>}
*/
export async function deleteMultipleBackgrounds(indices) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
// Get all items first
const getAllRequest = store.getAll();
return new Promise((resolve, reject) => {
getAllRequest.onsuccess = () => {
const items = getAllRequest.result;
const idsToDelete = indices.filter((index) => items[index]).map((index) => items[index].id);
// Delete all selected items
let completed = 0;
const total = idsToDelete.length;
if (total === 0) {
resolve();
return;
}
idsToDelete.forEach((id) => {
const deleteRequest = store.delete(id);
deleteRequest.onsuccess = () => {
completed++;
if (completed === total) {
resolve();
}
};
deleteRequest.onerror = () => reject(deleteRequest.error);
});
};
getAllRequest.onerror = () => reject(getAllRequest.error);
});
}
/**
* Clear all custom backgrounds
* @returns {Promise<void>}
@@ -166,6 +258,35 @@ export async function getBackgroundCount() {
});
}
/**
* Update a background's metadata by ID
* @param {number} id - The background ID
* @param {Object} metadata - Metadata to update
* @returns {Promise<void>}
*/
export async function updateBackgroundMetadata(id, metadata) {
const db = await openDB();
const transaction = db.transaction(STORE_NAME, 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(id);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const item = request.result;
if (item) {
Object.assign(item, metadata);
item.updatedAt = Date.now();
const updateRequest = store.put(item);
updateRequest.onsuccess = () => resolve();
updateRequest.onerror = () => reject(updateRequest.error);
} else {
reject(new Error('Background not found'));
}
};
request.onerror = () => reject(request.error);
});
}
/**
* Migrate backgrounds from localStorage to IndexedDB
* @returns {Promise<boolean>} True if migration occurred
@@ -188,7 +309,7 @@ export async function migrateFromLocalStorage() {
}
// Filter out null/empty values
backgrounds = backgrounds.filter(bg => bg && bg.trim() !== '');
backgrounds = backgrounds.filter((bg) => bg && bg.trim() !== '');
if (backgrounds.length === 0) {
return false;

View File

@@ -209,7 +209,7 @@
},
{
"name": "quoteType",
"value": "api"
"value": "quote_pack"
},
{
"name": "backgroundFilter",

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