feat: new default quotes experience, improve added page

This commit is contained in:
David Ralph
2026-01-25 17:34:59 +00:00
parent 6d209e10fb
commit 2918033afa
9 changed files with 261 additions and 93 deletions

View File

@@ -21,7 +21,7 @@
.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 +62,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 +127,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 +171,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;
}
}
}
}

View File

@@ -1,9 +1,16 @@
import variables from 'config/variables';
import React, { memo, useState, useMemo } from 'react';
import { MdAutoFixHigh, MdOutlineArrowForward, MdOutlineOpenInNew, MdCheckCircle } from 'react-icons/md';
import {
MdAutoFixHigh,
MdOutlineArrowForward,
MdOutlineOpenInNew,
MdCheckCircle,
MdOutlineUploadFile,
MdClose,
} from 'react-icons/md';
import placeholderIcon from 'assets/icons/marketplace-placeholder.png';
import { Button } from 'components/Elements';
import { Button, Tooltip } from 'components/Elements';
import Dropdown from '../../../../components/Form/Settings/Dropdown/Dropdown';
function filterItems(item, filter, categoryFilter) {
@@ -28,7 +35,29 @@ 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
@@ -73,28 +102,62 @@ function ItemCard({ item, toggleFunction, type, onCollection, isCurator, isInsta
};
};
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 && (
{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" style={getBadgeStyle()}>
<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 +169,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>
@@ -136,6 +196,8 @@ function Items({
showCreateYourOwn,
filterOptions = false,
onSortChange,
isAdded = false,
onUninstall,
}) {
const [selectedCategory, setSelectedCategory] = useState('all');
const [sortType, setSortType] = useState(localStorage.getItem('sortMarketplace') || 'a-z');
@@ -239,6 +301,8 @@ function Items({
type={type}
onCollection={onCollection}
isInstalled={installedNames.has(item.name)}
isAdded={isAdded}
onUninstall={onUninstall}
key={index}
/>
))}

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

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

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

@@ -23,7 +23,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 +101,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`) },
]}
/>

View File

@@ -599,10 +599,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

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

View File

@@ -76,6 +76,14 @@ export function uninstall(type, name) {
const installed = JSON.parse(localStorage.getItem('installed'));
for (let i = 0; i < installed.length; i++) {
if (installed[i].name === name) {
// Track uninstalled pack IDs to prevent auto-reinstall
if (installed[i].id) {
const uninstalledPacks = JSON.parse(localStorage.getItem('uninstalledPacks') || '[]');
if (!uninstalledPacks.includes(installed[i].id)) {
uninstalledPacks.push(installed[i].id);
localStorage.setItem('uninstalledPacks', JSON.stringify(uninstalledPacks));
}
}
installed.splice(i, 1);
break;
}