diff --git a/bun.lock b/bun.lock
index ad2a5ac2..e02ba835 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,8 +11,8 @@
"@floating-ui/react-dom": "2.1.6",
"@fontsource/inter": "^5.2.8",
"@fontsource/lexend-deca": "5.0.14",
+ "@fontsource/montserrat": "5.0.19",
"@sentry/react": "^10.36.0",
- "blurhash": "^2.0.5",
"fast-blurhash": "^1.1.4",
"image-conversion": "^2.1.1",
"react": "^19.2.3",
diff --git a/package.json b/package.json
index 7dd0d182..9a58de9c 100644
--- a/package.json
+++ b/package.json
@@ -24,8 +24,8 @@
"@floating-ui/react-dom": "2.1.6",
"@fontsource/inter": "^5.2.8",
"@fontsource/lexend-deca": "5.0.14",
+ "@fontsource/montserrat": "5.0.19",
"@sentry/react": "^10.36.0",
- "blurhash": "^2.0.5",
"fast-blurhash": "^1.1.4",
"image-conversion": "^2.1.1",
"react": "^19.2.3",
diff --git a/src/components/Elements/MainModal/scss/marketplace/_main.scss b/src/components/Elements/MainModal/scss/marketplace/_main.scss
index 7c6a5732..6d6deea0 100644
--- a/src/components/Elements/MainModal/scss/marketplace/_main.scss
+++ b/src/components/Elements/MainModal/scss/marketplace/_main.scss
@@ -184,6 +184,64 @@
}
}
+.itemPage {
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 30px;
+ flex-wrap: wrap;
+
+ .itemContent {
+ display: flex;
+ flex-flow: column;
+ gap: 25px;
+ flex: 1;
+
+ img {
+ width: 100%;
+ height: auto;
+ border-radius: 12px;
+ }
+
+ table {
+ table-layout: fixed;
+ width: 100%;
+ word-wrap: break-word !important;
+ font-size: 16px;
+ border-collapse: collapse;
+ }
+
+ .itemTop {
+ 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;
+ }
+ }
+ }
+}
+
.emptyItems {
width: 100%;
height: 100%;
diff --git a/src/components/Form/Settings/Dropdown/Dropdown.jsx b/src/components/Form/Settings/Dropdown/Dropdown.jsx
index 0e26f3fa..d4585d45 100644
--- a/src/components/Form/Settings/Dropdown/Dropdown.jsx
+++ b/src/components/Form/Settings/Dropdown/Dropdown.jsx
@@ -1,6 +1,5 @@
import variables from 'config/variables';
import { memo, useState, useCallback, useRef, useEffect } from 'react';
-import { createPortal } from 'react-dom';
import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md';
import { toast } from 'react-toastify';
@@ -9,51 +8,25 @@ import EventBus from 'utils/eventbus';
import './Dropdown.scss';
const Dropdown = memo((props) => {
- const [value, setValue] = useState(localStorage.getItem(props.name) || props.items[0]?.value);
+ const [value, setValue] = useState(
+ localStorage.getItem(props.name) || props.items[0]?.value,
+ );
const [isOpen, setIsOpen] = useState(false);
- const [isClosing, setIsClosing] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
- const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
const containerRef = useRef(null);
- const controlRef = useRef(null);
- const menuRef = useRef(null);
const optionsRef = useRef([]);
- const closeDropdown = useCallback(() => {
- setIsClosing(true);
- setTimeout(() => {
- setIsOpen(false);
- setIsClosing(false);
- setFocusedIndex(-1);
- }, 200); // Match animation duration
- }, []);
-
useEffect(() => {
const handleClickOutside = (event) => {
- if (
- containerRef.current &&
- !containerRef.current.contains(event.target) &&
- menuRef.current &&
- !menuRef.current.contains(event.target)
- ) {
- closeDropdown();
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
+ setIsOpen(false);
+ setFocusedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
- }, [closeDropdown]);
-
- useEffect(() => {
- if (isOpen && controlRef.current) {
- const rect = controlRef.current.getBoundingClientRect();
- setMenuPosition({
- top: rect.bottom + 4,
- left: rect.left,
- width: rect.width,
- });
- }
- }, [isOpen]);
+ }, []);
const onChange = useCallback(
(newValue) => {
@@ -64,7 +37,8 @@ const Dropdown = memo((props) => {
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
setValue(newValue);
- closeDropdown();
+ setIsOpen(false);
+ setFocusedIndex(-1);
if (!props.noSetting) {
localStorage.setItem(props.name, newValue);
@@ -95,23 +69,18 @@ const Dropdown = memo((props) => {
case 'Enter':
case ' ':
e.preventDefault();
- if (isOpen) {
- closeDropdown();
- } else {
- setIsOpen(true);
- }
+ setIsOpen(!isOpen);
break;
case 'Escape':
- closeDropdown();
+ 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,
- );
+ setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev));
}
break;
case 'ArrowUp':
@@ -157,16 +126,8 @@ const Dropdown = memo((props) => {
)}
{
- if (props.disabled) return;
- if (isOpen) {
- closeDropdown();
- } else {
- setIsOpen(true);
- }
- }}
+ onClick={() => !props.disabled && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
role="button"
aria-haspopup="listbox"
@@ -177,39 +138,27 @@ const Dropdown = memo((props) => {
{selectedItem?.text || value}
- {(isOpen || isClosing) &&
- createPortal(
-
- {props.items.map((item, index) =>
- item !== null ? (
-
(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}
- >
- {item.text}
- {value === item.value && }
-
- ) : null,
- )}
-
,
- document.body,
- )}
+ {isOpen && (
+
+ {props.items.map((item, index) =>
+ item !== null ? (
+
(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}
+ >
+ {item.text}
+ {value === item.value && }
+
+ ) : null,
+ )}
+
+ )}
);
});
diff --git a/src/components/Form/Settings/Dropdown/Dropdown.scss b/src/components/Form/Settings/Dropdown/Dropdown.scss
index 29fcf32e..637fad37 100644
--- a/src/components/Form/Settings/Dropdown/Dropdown.scss
+++ b/src/components/Form/Settings/Dropdown/Dropdown.scss
@@ -13,25 +13,15 @@
}
}
-@include keyframes(dropdownSlideOut) {
- 0% {
- opacity: 1;
- transform: translateY(0);
- }
-
- 100% {
- opacity: 0;
- transform: translateY(-10px);
- }
-}
-
.dropdown {
position: relative;
width: 300px;
+ margin-top: 10px;
gap: 8px;
display: flex;
flex-flow: column;
+
&.disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -131,99 +121,98 @@
transform: rotate(180deg);
}
}
-}
-// Dropdown menu and options are now portaled to body, so they need to be at root level
-.dropdown-menu {
- max-height: 250px;
- overflow-y: auto;
- z-index: 10000;
- @include animation(dropdownSlideIn 0.2s ease-out);
+ .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);
- &.closing {
- @include animation(dropdownSlideOut 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;
+ 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);
}
- &:hover {
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
@include themed {
- background: t($color);
+ 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;
}
}
}
}
-
-.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;
- }
- }
-}
diff --git a/src/features/marketplace/components/Items/Items.jsx b/src/features/marketplace/components/Items/Items.jsx
index ee1ef7f4..3884311f 100644
--- a/src/features/marketplace/components/Items/Items.jsx
+++ b/src/features/marketplace/components/Items/Items.jsx
@@ -1,13 +1,16 @@
import variables from 'config/variables';
import React, { memo, useState, useMemo } from 'react';
import {
+ MdAutoFixHigh,
+ MdOutlineArrowForward,
+ MdOutlineOpenInNew,
MdCheckCircle,
MdOutlineUploadFile,
MdClose,
} from 'react-icons/md';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
-import { Tooltip } from 'components/Elements';
+import { Button, Tooltip } from 'components/Elements';
import Dropdown from '../../../../components/Form/Settings/Dropdown/Dropdown';
function filterItems(item, filter, categoryFilter) {
@@ -57,6 +60,40 @@ function getTypeTranslationKey(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}`;
+
+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;
+
+ const isSideloaded = item.sideload === true;
+
const isSideloaded = item.sideload === true;
return (
@@ -92,7 +129,7 @@ function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInsta
)}
{isInstalled && item.colour && !isSideloaded && (
-
+
)}
diff --git a/src/features/quote/options/QuoteOptions.jsx b/src/features/quote/options/QuoteOptions.jsx
index 155f1d3d..88e7a413 100644
--- a/src/features/quote/options/QuoteOptions.jsx
+++ b/src/features/quote/options/QuoteOptions.jsx
@@ -31,14 +31,6 @@ const QuoteOptions = ({ currentSubSection, onSubSectionChange, sectionName }) =>
}
return type;
});
-
- // Migration: Force authorDetails on for users upgrading from older versions
- useState(() => {
- if (localStorage.getItem('authorDetails') === null) {
- localStorage.setItem('authorDetails', 'true');
- }
- });
-
const [customQuote, setCustomQuote] = useState(getCustom());
const handleCustomQuote = (e, text, index, type) => {