general fixes & enhancements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Category } from '../types';
|
||||
import ModalShell from './ModalShell';
|
||||
|
||||
interface CategoryEditModalProps {
|
||||
category?: Category;
|
||||
@@ -12,45 +13,23 @@ interface CategoryEditModalProps {
|
||||
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
||||
const [name, setName] = useState(category ? category.name : '');
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Category Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
{edit && (
|
||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalShell
|
||||
title={edit ? 'Edit Category' : 'Add Category'}
|
||||
edit={edit}
|
||||
onClose={onClose}
|
||||
onSave={() => onSave(name)}
|
||||
onDelete={edit ? onDelete : undefined}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Category Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryEditModal;
|
||||
export default CategoryEditModal;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getClockSizeClass } from './utils/styleUtils';
|
||||
|
||||
interface ClockProps {
|
||||
config: {
|
||||
@@ -9,10 +10,9 @@ interface ClockProps {
|
||||
format: string;
|
||||
};
|
||||
};
|
||||
getClockSizeClass: (size: string) => string;
|
||||
}
|
||||
|
||||
const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
||||
const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,4 +66,4 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Clock;
|
||||
export default Clock;
|
||||
@@ -28,7 +28,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const isSaving = useRef(false);
|
||||
|
||||
@@ -64,8 +63,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleAddWallpaper = async (name: string, url: string) => {
|
||||
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
||||
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||
const newWallpaper = await promise;
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
@@ -75,16 +74,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddWallpaperFile = async (file: File) => {
|
||||
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||
}));
|
||||
};
|
||||
const handleAddWallpaper = (name: string, url: string) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||
|
||||
const handleAddWallpaperFile = (file: File) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||
|
||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||
try {
|
||||
@@ -140,7 +134,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
||||
isVisible ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ModalShellProps {
|
||||
title: string;
|
||||
edit: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
{edit && onDelete && (
|
||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalShell;
|
||||
@@ -12,17 +12,14 @@ interface ServerWidgetProps {
|
||||
};
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-400 text-green-400';
|
||||
case 'offline':
|
||||
return 'bg-red-400 text-red-400';
|
||||
default:
|
||||
return 'bg-slate-400 text-slate-400';
|
||||
}
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: 'bg-green-400 text-green-400',
|
||||
offline: 'bg-red-400 text-red-400',
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): string =>
|
||||
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
|
||||
|
||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||
const serversRef = useRef(config.serverWidget.servers);
|
||||
|
||||
+25
-28
@@ -1,8 +1,8 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { Wallpaper as WallpaperType } from '../types';
|
||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
|
||||
|
||||
interface WallpaperProps {
|
||||
wallpaperNames: string[];
|
||||
@@ -13,28 +13,18 @@ interface WallpaperProps {
|
||||
wallpaperVersion: number;
|
||||
}
|
||||
|
||||
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
|
||||
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
|
||||
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseFrequencyToMs = (freq: string): number => {
|
||||
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const match = freq.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
||||
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
||||
};
|
||||
|
||||
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
||||
if (wallpaperCount <= 1) return 0;
|
||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
||||
return (currentIndex + offset) % wallpaperCount;
|
||||
};
|
||||
|
||||
const MAX_WALLPAPER_URL_CACHE = 3;
|
||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||
|
||||
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||
wallpaperUrlCache.set(name, resolved);
|
||||
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||
const oldest = wallpaperUrlCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
wallpaperUrlCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||
@@ -65,18 +55,19 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
wallpaperUrlCache.set(name, resolved);
|
||||
rememberWallpaperUrl(name, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||
const resolvedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const updateWallpaper = async () => {
|
||||
if (wallpaperNames.length === 0) {
|
||||
setImageUrl(undefined);
|
||||
if (!cancelled) setImageUrl(undefined);
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||
@@ -89,7 +80,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||
: 0;
|
||||
const now = Date.now();
|
||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
||||
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
|
||||
|
||||
let storedIndex =
|
||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||
@@ -107,6 +98,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
if (tried.has(resolvedIndex)) break;
|
||||
tried.add(resolvedIndex);
|
||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||
if (cancelled) return;
|
||||
if (url) {
|
||||
resolvedUrl = url;
|
||||
break;
|
||||
@@ -114,6 +106,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const nextLastChange = shouldRotate
|
||||
? new Date().toISOString()
|
||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||
@@ -126,10 +120,13 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
}),
|
||||
);
|
||||
|
||||
resolvedRef.current = true;
|
||||
setImageUrl(resolvedUrl);
|
||||
};
|
||||
updateWallpaper();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||
|
||||
if (!imageUrl) return null;
|
||||
@@ -150,4 +147,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
);
|
||||
};
|
||||
|
||||
export default Wallpaper;
|
||||
export default Wallpaper;
|
||||
+87
-104
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Website } from '../types';
|
||||
import { getWebsiteIcon } from './utils/iconService';
|
||||
import ModalShell from './ModalShell';
|
||||
|
||||
interface WebsiteEditModalProps {
|
||||
website?: Website;
|
||||
@@ -27,6 +28,9 @@ interface IconMetadata {
|
||||
|
||||
let iconMetadataCache: IconMetadata[] | null = null;
|
||||
|
||||
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||
|
||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||
const [name, setName] = useState(website ? website.name : '');
|
||||
const [url, setUrl] = useState(website ? website.url : '');
|
||||
@@ -37,6 +41,12 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
iconMetadataCache = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensureIconMetadata = () => {
|
||||
if (iconMetadataCache) {
|
||||
setIconMetadata(iconMetadataCache);
|
||||
@@ -68,10 +78,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
filtered.push(ic);
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
if (ic.colors) {
|
||||
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
|
||||
for (const color of colors) {
|
||||
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
|
||||
if (ic.colors && typeof ic.colors === 'object') {
|
||||
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||
for (const color of colors as string[]) {
|
||||
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||
filtered.push({ ...ic, name: color });
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
@@ -96,109 +106,82 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ id: website?.id, name, url, icon });
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
{icon ? (
|
||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||
) : (
|
||||
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<ModalShell
|
||||
title={edit ? 'Edit Website' : 'Add Website'}
|
||||
edit={edit}
|
||||
onClose={onClose}
|
||||
onSave={() => onSave({ id: website?.id, name, url, icon })}
|
||||
onDelete={edit ? onDelete : undefined}
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
{icon ? (
|
||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||
) : (
|
||||
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Icon URL or name"
|
||||
value={icon}
|
||||
onChange={(e) => {
|
||||
setIcon(e.target.value);
|
||||
setIconQuery(e.target.value);
|
||||
}}
|
||||
onFocus={ensureIconMetadata}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
{filteredIcons.length > 0 && (
|
||||
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
||||
{filteredIcons.map(iconData => (
|
||||
<div
|
||||
key={iconData.name}
|
||||
onClick={() => {
|
||||
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||
setIcon(iconUrl);
|
||||
setFilteredIcons([]);
|
||||
}}
|
||||
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||
>
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
||||
alt={iconData.name}
|
||||
className="h-6 w-6 mr-2"
|
||||
/>
|
||||
<span>{iconData.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
{edit && (
|
||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={handleSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Icon URL or name"
|
||||
value={icon}
|
||||
onChange={(e) => {
|
||||
setIcon(e.target.value);
|
||||
setIconQuery(e.target.value);
|
||||
}}
|
||||
onFocus={ensureIconMetadata}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
{filteredIcons.length > 0 && (
|
||||
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
||||
{filteredIcons.map((iconData, index) => (
|
||||
<div
|
||||
key={`${iconData.name}-${index}`}
|
||||
onClick={() => {
|
||||
setIcon(getIconPickUrl(iconData));
|
||||
setFilteredIcons([]);
|
||||
}}
|
||||
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||
>
|
||||
<img
|
||||
src={getIconPickUrl(iconData)}
|
||||
alt={iconData.name}
|
||||
className="h-6 w-6 mr-2"
|
||||
/>
|
||||
<span>{iconData.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsiteEditModal;
|
||||
export default WebsiteEditModal;
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { Website } from '../types';
|
||||
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
||||
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
|
||||
|
||||
interface WebsiteTileProps {
|
||||
website: Website;
|
||||
@@ -10,46 +12,6 @@ interface WebsiteTileProps {
|
||||
tileSize?: string;
|
||||
}
|
||||
|
||||
const getTileSizeClass = (size: string | undefined) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-28 h-28';
|
||||
case 'medium':
|
||||
return 'w-32 h-32';
|
||||
case 'large':
|
||||
return 'w-36 h-36';
|
||||
default:
|
||||
return 'w-32 h-32';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getIconPixelSize = (size: string | undefined): number => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 34;
|
||||
case 'medium':
|
||||
return 42;
|
||||
case 'large':
|
||||
return 48;
|
||||
default:
|
||||
return 40;
|
||||
}
|
||||
};
|
||||
|
||||
const getIconLoadingPixelSize = (size: string | undefined): number => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 24;
|
||||
case 'medium':
|
||||
return 32;
|
||||
case 'large':
|
||||
return 40;
|
||||
default:
|
||||
return 32;
|
||||
}
|
||||
};
|
||||
|
||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -141,15 +103,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
</a>
|
||||
{isEditing && (
|
||||
<div className="liquid-surface liquid-edit-toolbar">
|
||||
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||
</svg></button>
|
||||
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||
</svg></button>
|
||||
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||
</svg></button>
|
||||
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
|
||||
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
|
||||
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import Dropdown from '../Dropdown';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config } from '../../types';
|
||||
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||
|
||||
interface ClockTabProps {
|
||||
config: Config;
|
||||
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
||||
name="clock.size"
|
||||
value={config.clock.size}
|
||||
onChange={(e) => updateClock({ size: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
options={SIZE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import Dropdown from '../Dropdown';
|
||||
import { Config } from '../../types';
|
||||
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||
|
||||
interface GeneralTabProps {
|
||||
config: Config;
|
||||
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
||||
name="titleSize"
|
||||
value={config.titleSize}
|
||||
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
options={SIZE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
export const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
interface RangeSliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
valueSuffix?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const RangeSlider: React.FC<RangeSliderProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
valueSuffix,
|
||||
formatValue,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">{label}</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(value, min, max)}
|
||||
/>
|
||||
<span className="min-w-20 text-right text-sm text-slate-200">
|
||||
{formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangeSlider;
|
||||
@@ -2,19 +2,14 @@ import React, { useState } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config, Server } from '../../types';
|
||||
import RangeSlider from './RangeSlider';
|
||||
import { TrashIcon } from '../icons';
|
||||
|
||||
interface ServerWidgetTabProps {
|
||||
config: Config;
|
||||
onChange: (updates: Partial<Config>) => void;
|
||||
}
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerAddress, setNewServerAddress] = useState('');
|
||||
@@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
</div>
|
||||
{config.serverWidget.enabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="60"
|
||||
value={config.serverWidget.pingFrequency}
|
||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<RangeSlider
|
||||
label="Ping Frequency"
|
||||
value={config.serverWidget.pingFrequency}
|
||||
min={5}
|
||||
max={60}
|
||||
valueSuffix="s"
|
||||
onChange={(value) => updateServerWidget({ pingFrequency: value })}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
@@ -103,20 +91,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||
aria-label={`Remove ${server.name}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-trash"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||
/>
|
||||
</svg>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -156,4 +131,4 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerWidgetTab;
|
||||
export default ServerWidgetTab;
|
||||
@@ -1,6 +1,14 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import Dropdown from '../Dropdown';
|
||||
import { Config, Wallpaper } from '../../types';
|
||||
import RangeSlider from './RangeSlider';
|
||||
import { TrashIcon } from '../icons';
|
||||
import {
|
||||
getWallpaperFrequencyHours,
|
||||
formatWallpaperFrequency,
|
||||
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||
} from '../utils/wallpaperUtils';
|
||||
|
||||
interface ThemeTabProps {
|
||||
config: Config;
|
||||
@@ -14,31 +22,6 @@ interface ThemeTabProps {
|
||||
onRandomWallpaper: () => void;
|
||||
}
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||
|
||||
const clampWallpaperFrequencyHours = (hours: number): number =>
|
||||
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
|
||||
|
||||
const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||
const value = Number(match[1]);
|
||||
const hours = match[2] === 'd' ? value * 24 : value;
|
||||
return clampWallpaperFrequencyHours(hours);
|
||||
};
|
||||
|
||||
const formatWallpaperFrequency = (hours: number): string =>
|
||||
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
|
||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
@@ -52,7 +35,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
}) => {
|
||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||
|
||||
const handleAddWallpaper = async () => {
|
||||
@@ -76,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
if (!file) return;
|
||||
try {
|
||||
await onAddWallpaperFile(file);
|
||||
} catch (error: any) {
|
||||
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
|
||||
console.error(error);
|
||||
}
|
||||
e.target.value = '';
|
||||
@@ -96,74 +78,39 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||
step="1"
|
||||
value={wallpaperFrequencyHours}
|
||||
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(
|
||||
wallpaperFrequencyHours,
|
||||
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||
)}
|
||||
/>
|
||||
<span className="w-20 text-right text-sm text-slate-200">
|
||||
{formatWallpaperFrequency(wallpaperFrequencyHours)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<RangeSlider
|
||||
label="Change Frequency"
|
||||
value={wallpaperFrequencyHours}
|
||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||
formatValue={formatWallpaperFrequency}
|
||||
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.wallpaperBlur}
|
||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.wallpaperBrightness}
|
||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.wallpaperOpacity}
|
||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<RangeSlider
|
||||
label="Wallpaper Blur"
|
||||
value={config.wallpaperBlur}
|
||||
min={0}
|
||||
max={50}
|
||||
valueSuffix="px"
|
||||
onChange={(value) => onChange({ wallpaperBlur: value })}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Wallpaper Brightness"
|
||||
value={config.wallpaperBrightness}
|
||||
min={0}
|
||||
max={200}
|
||||
valueSuffix="%"
|
||||
onChange={(value) => onChange({ wallpaperBrightness: value })}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Wallpaper Opacity"
|
||||
value={config.wallpaperOpacity}
|
||||
min={1}
|
||||
max={100}
|
||||
valueSuffix="%"
|
||||
onChange={(value) => onChange({ wallpaperOpacity: value })}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -178,20 +125,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||
aria-label={`Delete ${wallpaper.name}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-trash"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||
/>
|
||||
</svg>
|
||||
<TrashIcon size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -254,7 +188,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -283,4 +216,4 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeTab;
|
||||
export default ThemeTab;
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PencilIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusIcon: React.FC<IconProps> = ({ size = 22, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon: React.FC<IconProps> = ({ size = 16, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronLeftIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { memo } from 'react';
|
||||
import WebsiteTile from '../WebsiteTile';
|
||||
import { Category, Website } from '../../types';
|
||||
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
|
||||
import { PencilIcon, PlusIcon } from '../icons';
|
||||
|
||||
interface CategoryGroupProps {
|
||||
category: Category;
|
||||
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
|
||||
setAddingWebsite: (category: Category) => void;
|
||||
setEditingWebsite: (website: Website) => void;
|
||||
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
||||
getHorizontalAlignmentClass: (alignment: string) => string;
|
||||
horizontalAlignment: string;
|
||||
tileSize?: string;
|
||||
}
|
||||
|
||||
const getAddTileSizeClass = (size: string | undefined) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-28 h-28';
|
||||
case 'medium':
|
||||
return 'w-32 h-32';
|
||||
case 'large':
|
||||
return 'w-36 h-36';
|
||||
default:
|
||||
return 'w-32 h-32';
|
||||
}
|
||||
};
|
||||
|
||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||
category,
|
||||
isEditing,
|
||||
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||
setAddingWebsite,
|
||||
setEditingWebsite,
|
||||
handleMoveWebsite,
|
||||
getHorizontalAlignmentClass,
|
||||
horizontalAlignment,
|
||||
tileSize,
|
||||
}) => {
|
||||
return (
|
||||
<div key={category.id} className="w-full">
|
||||
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||
<div className={`flex ${getAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||
{isEditing && (
|
||||
<button
|
||||
@@ -53,13 +40,11 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||
aria-label={`Edit ${category.name} category`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||
</svg>
|
||||
<PencilIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||
<div className={`flex flex-wrap ${getAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||
{category.websites.map((website) => (
|
||||
<WebsiteTile
|
||||
key={website.id}
|
||||
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => setAddingWebsite(category)}
|
||||
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getAddTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||
aria-label={`Add website to ${category.name}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
<PlusIcon size={28} />
|
||||
<span className="text-sm font-bold">Add</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -88,4 +70,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CategoryGroup);
|
||||
export default memo(CategoryGroup);
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
import { PencilIcon } from '../icons';
|
||||
|
||||
interface EditButtonProps {
|
||||
isEditing: boolean;
|
||||
@@ -13,13 +13,11 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
||||
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
<PencilIcon size={16} />
|
||||
{isEditing ? 'Done' : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditButton;
|
||||
export default EditButton;
|
||||
@@ -1,63 +1,17 @@
|
||||
import Clock from '../Clock';
|
||||
import { Config } from '../../types';
|
||||
import { getTitleSizeClass } from '../utils/styleUtils';
|
||||
|
||||
interface HeaderProps {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const getClockSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-3xl';
|
||||
case 'small':
|
||||
return 'text-4xl';
|
||||
case 'medium':
|
||||
return 'text-5xl';
|
||||
case 'large':
|
||||
return 'text-6xl';
|
||||
default:
|
||||
return 'text-5xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitleSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-4xl';
|
||||
case 'small':
|
||||
return 'text-5xl';
|
||||
case 'medium':
|
||||
return 'text-6xl';
|
||||
case 'large':
|
||||
return 'text-7xl';
|
||||
default:
|
||||
return 'text-6xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getSubtitleSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-lg';
|
||||
case 'small':
|
||||
return 'text-xl';
|
||||
case 'medium':
|
||||
return 'text-2xl';
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
|
||||
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||
return (
|
||||
<>
|
||||
{config.clock.enabled && (
|
||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
||||
<Clock config={config} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||
@@ -75,4 +29,4 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
checkChromeStorageLocalAvailable,
|
||||
removeWallpaperFromChromeStorageLocal,
|
||||
} from '../utils/StorageLocalManager';
|
||||
import { getFileNameFromUrl } from '../utils/urlUtils';
|
||||
|
||||
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||
@@ -44,16 +45,6 @@ const safeParse = (value: string | null): unknown => {
|
||||
const toStorageString = (value: unknown): string =>
|
||||
typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
const getWallpaperNameFromUrl = (url: URL): string => {
|
||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||
if (!pathName) return url.hostname;
|
||||
try {
|
||||
return decodeURIComponent(pathName);
|
||||
} catch {
|
||||
return pathName;
|
||||
}
|
||||
};
|
||||
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
@@ -121,7 +112,7 @@ export const ConfigurationService = {
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||
}
|
||||
const finalName = name.trim() || getWallpaperNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||
return { name: finalName, url: parsedUrl.href };
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// TypeScript interface for window.chrome
|
||||
import { getFileNameFromUrl } from './urlUtils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome?: {
|
||||
@@ -23,7 +25,6 @@ const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
|
||||
const getIconCacheKey = (sourceUrl: string): string =>
|
||||
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
|
||||
|
||||
|
||||
/**
|
||||
* Checks if chrome.storage.local is available and caches the result.
|
||||
*/
|
||||
@@ -37,54 +38,60 @@ export function checkChromeStorageLocalAvailable(): boolean {
|
||||
return isChromeStorageLocalAvailable;
|
||||
}
|
||||
|
||||
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||
if (!checkChromeStorageLocalAvailable()) return null;
|
||||
type StorageResult = { [key: string]: string };
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
const chromeLocalCall = <T>(
|
||||
operation: (callback: (result: T) => void) => void,
|
||||
): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
if (!window.chrome?.storage?.local) {
|
||||
resolve(null);
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = getIconCacheKey(sourceUrl);
|
||||
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
|
||||
operation((result) => {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
resolve(null);
|
||||
return;
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(result);
|
||||
}
|
||||
resolve(result[key] || null);
|
||||
});
|
||||
});
|
||||
|
||||
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||
if (!checkChromeStorageLocalAvailable()) return null;
|
||||
try {
|
||||
const key = getIconCacheKey(sourceUrl);
|
||||
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get([key], cb),
|
||||
);
|
||||
return result[key] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
||||
if (!checkChromeStorageLocalAvailable()) return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (!window.chrome?.storage?.local) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () {
|
||||
resolve(!window.chrome?.runtime?.lastError);
|
||||
});
|
||||
});
|
||||
try {
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
|
||||
if (!checkChromeStorageLocalAvailable()) return false;
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (!window.chrome?.storage?.local) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () {
|
||||
resolve(!window.chrome?.runtime?.lastError);
|
||||
});
|
||||
});
|
||||
try {
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,22 +120,13 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||
}
|
||||
finalName = finalName || parsedUrl.pathname.split('/').filter(Boolean).pop() || parsedUrl.hostname;
|
||||
finalName = finalName || getFileNameFromUrl(parsedUrl);
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!window.chrome?.storage?.local) {
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
return;
|
||||
}
|
||||
window.chrome.storage.local.set({ [finalName]: url }, function () {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}).then(() => finalName);
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
|
||||
);
|
||||
return finalName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,19 +139,10 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
|
||||
if (!checkChromeStorageLocalAvailable()) {
|
||||
throw new Error('chrome.storage.local is not available');
|
||||
}
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
if (window.chrome?.storage?.local) {
|
||||
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(result[name] || null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
}
|
||||
});
|
||||
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get([name], cb),
|
||||
);
|
||||
return result[name] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
|
||||
if (!checkChromeStorageLocalAvailable()) {
|
||||
throw new Error('chrome.storage.local is not available');
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (window.chrome?.storage?.local) {
|
||||
window.chrome.storage.local.remove(name, function () {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
}
|
||||
});
|
||||
}
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.remove(name, cb),
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,19 @@ import {
|
||||
} from './StorageLocalManager';
|
||||
|
||||
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||
const MAX_RESOLVED_ICONS = 50;
|
||||
const resolvedIconCache = new Map<string, string>();
|
||||
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
||||
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
||||
|
||||
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
|
||||
resolvedIconCache.set(iconUrl, dataUrl);
|
||||
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
|
||||
const oldest = resolvedIconCache.keys().next().value;
|
||||
if (oldest !== undefined) resolvedIconCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
||||
|
||||
const isCacheableIconUrl = (value: string): boolean => {
|
||||
@@ -38,9 +47,14 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
async function getWebsiteIcon(url: string): Promise<string> {
|
||||
async function getWebsiteIcon(rawUrl: string): Promise<string> {
|
||||
let targetUrl = rawUrl.trim();
|
||||
if (targetUrl && !/^https?:\/\//i.test(targetUrl)) {
|
||||
targetUrl = `https://${targetUrl}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(targetUrl);
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
@@ -48,7 +62,7 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
||||
if (appleTouchIcon) {
|
||||
const href = appleTouchIcon.getAttribute('href');
|
||||
if (href) {
|
||||
return new URL(href, url).href;
|
||||
return new URL(href, targetUrl).href;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,15 +70,19 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
||||
if (iconLink) {
|
||||
const href = iconLink.getAttribute('href');
|
||||
if (href) {
|
||||
return new URL(href, url).href;
|
||||
return new URL(href, targetUrl).href;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching and parsing HTML for icon:', error);
|
||||
}
|
||||
|
||||
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
||||
try {
|
||||
const hostname = new URL(targetUrl).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=128`;
|
||||
} catch {
|
||||
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
|
||||
}
|
||||
}
|
||||
|
||||
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
@@ -80,7 +98,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
|
||||
.then((cachedIcon) => {
|
||||
if (isValidCachedIcon(cachedIcon)) {
|
||||
resolvedIconCache.set(iconUrl, cachedIcon);
|
||||
rememberResolvedIcon(iconUrl, cachedIcon);
|
||||
return cachedIcon;
|
||||
}
|
||||
return null;
|
||||
@@ -121,7 +139,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
resolvedIconCache.set(iconUrl, dataUrl);
|
||||
rememberResolvedIcon(iconUrl, dataUrl);
|
||||
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
||||
return dataUrl;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export const SIZE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
];
|
||||
|
||||
export const TILE_SIZE_CLASSES: Record<string, string> = {
|
||||
small: 'w-28 h-28',
|
||||
medium: 'w-32 h-32',
|
||||
large: 'w-36 h-36',
|
||||
};
|
||||
|
||||
export const getTileSizeClass = (size: string | undefined): string =>
|
||||
TILE_SIZE_CLASSES[size ?? ''] ?? 'w-32 h-32';
|
||||
|
||||
export const ICON_PIXEL_SIZES: Record<string, number> = {
|
||||
small: 34,
|
||||
medium: 42,
|
||||
large: 48,
|
||||
};
|
||||
|
||||
export const getIconPixelSize = (size: string | undefined): number =>
|
||||
ICON_PIXEL_SIZES[size ?? ''] ?? 40;
|
||||
|
||||
export const ICON_LOADING_PIXEL_SIZES: Record<string, number> = {
|
||||
small: 24,
|
||||
medium: 32,
|
||||
large: 40,
|
||||
};
|
||||
|
||||
export const getIconLoadingPixelSize = (size: string | undefined): number =>
|
||||
ICON_LOADING_PIXEL_SIZES[size ?? ''] ?? 32;
|
||||
|
||||
export const CLOCK_SIZE_CLASSES: Record<string, string> = {
|
||||
tiny: 'text-3xl',
|
||||
small: 'text-4xl',
|
||||
medium: 'text-5xl',
|
||||
large: 'text-6xl',
|
||||
};
|
||||
|
||||
export const getClockSizeClass = (size: string): string =>
|
||||
CLOCK_SIZE_CLASSES[size] ?? 'text-5xl';
|
||||
|
||||
export const TITLE_SIZE_CLASSES: Record<string, string> = {
|
||||
tiny: 'text-4xl',
|
||||
small: 'text-5xl',
|
||||
medium: 'text-6xl',
|
||||
large: 'text-7xl',
|
||||
};
|
||||
|
||||
export const getTitleSizeClass = (size: string): string =>
|
||||
TITLE_SIZE_CLASSES[size] ?? 'text-6xl';
|
||||
|
||||
export const ALIGNMENT_CLASSES: Record<string, string> = {
|
||||
top: 'justify-start',
|
||||
left: 'justify-start',
|
||||
middle: 'justify-center',
|
||||
bottom: 'justify-end',
|
||||
right: 'justify-end',
|
||||
};
|
||||
|
||||
export const getAlignmentClass = (alignment: string): string =>
|
||||
ALIGNMENT_CLASSES[alignment] ?? 'justify-center';
|
||||
@@ -0,0 +1,9 @@
|
||||
export const getFileNameFromUrl = (url: URL): string => {
|
||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||
if (!pathName) return url.hostname;
|
||||
try {
|
||||
return decodeURIComponent(pathName);
|
||||
} catch {
|
||||
return pathName;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||
export const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||
export const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||
|
||||
export const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||
if (!frequency) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||
const value = parseInt(match[1], 10);
|
||||
const hours = match[2] === 'd' ? value * 24 : value;
|
||||
return Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, hours));
|
||||
};
|
||||
|
||||
export const getWallpaperFrequencyMs = (frequency: string): number =>
|
||||
getWallpaperFrequencyHours(frequency) * 60 * 60 * 1000;
|
||||
|
||||
export const formatWallpaperFrequency = (hours: number): string =>
|
||||
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
|
||||
export const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
||||
if (wallpaperCount <= 1) return 0;
|
||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
||||
return (currentIndex + offset) % wallpaperCount;
|
||||
};
|
||||
Reference in New Issue
Block a user