looking better

This commit is contained in:
2025-07-27 18:04:39 -03:00
parent 12ed7e1b9f
commit 181fd3b3ec
12 changed files with 324 additions and 153 deletions

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import ToggleSwitch from './ToggleSwitch';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { Server, Wallpaper } from '../types';
import { Trash } from 'lucide-react';
import Dropdown from './Dropdown';
import { baseWallpapers } from './utils/baseWallpapers';
@@ -37,6 +36,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
format: 'h:mm A',
...currentConfig.clock,
},
backgroundUrls: currentConfig.backgroundUrls || [],
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
});
const [activeTab, setActiveTab] = useState('general');
const [newServerName, setNewServerName] = useState('');
@@ -70,7 +71,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
}, 300); // This duration should match the transition duration
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
const { name, value } = e.target;
if (name.startsWith('serverWidget.')) {
const field = name.split('.')[1];
@@ -158,7 +159,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, backgroundUrl: newWallpaperUrl });
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
setNewWallpaperName('');
setNewWallpaperUrl('');
@@ -187,23 +188,20 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
};
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
setConfig({ ...config, backgroundUrl: base64 });
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, base64] });
};
reader.readAsDataURL(file);
}
};
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
const wallpaperIdentifier = wallpaper.url || wallpaper.base64;
const updatedUserWallpapers = userWallpapers.filter(w => (w.url || w.base64) !== wallpaperIdentifier);
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
if (config.backgroundUrl === (wallpaper.url || wallpaper.base64)) {
const nextWallpaper = baseWallpapers[0] || updatedUserWallpapers[0];
if (nextWallpaper) {
setConfig({ ...config, backgroundUrl: nextWallpaper.url || nextWallpaper.base64 });
}
}
const newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
setConfig({ ...config, backgroundUrls: newBackgroundUrls });
};
const allWallpapers = [...baseWallpapers, ...userWallpapers];
@@ -350,23 +348,27 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown
name="backgroundUrl"
value={config.backgroundUrl}
name="backgroundUrls"
value={config.backgroundUrls}
onChange={handleChange}
multiple
options={allWallpapers.map(w => ({
value: w.url || w.base64 || '',
label: (
<div className="flex items-center justify-between">
{w.name}
{!baseWallpapers.includes(w) && (
<div className="flex items-center justify-between w-full">
<span>{w.name}</span>
{!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteWallpaper(w);
}}
className="text-red-500 hover:text-red-400 ml-4"
className="text-red-500 hover:text-red-400 ml-4 p-1 rounded-full flex items-center justify-center"
>
<Trash size={16} />
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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>
</button>
)}
</div>
@@ -374,6 +376,24 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
}))}
/>
</div>
{Array.isArray(config.backgroundUrls) && config.backgroundUrls.length > 1 && (
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={handleChange}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
/>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4">
@@ -622,4 +642,4 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
);
};
export default ConfigurationModal;
export default ConfigurationModal;

View File

@@ -2,17 +2,16 @@ import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps {
options: { value: string; label: string }[];
value: string;
onChange: (e: { target: { name: string; value: string } }) => void;
value: string | string[];
onChange: (e: { target: { name: string; value: string | string[] } }) => void;
name?: string;
multiple?: boolean;
}
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...rest }) => {
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, multiple = false, ...rest }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOptionLabel = options.find(option => option.value === value)?.label || '';
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -26,14 +25,46 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
}, []);
const handleOptionClick = (optionValue: string) => {
let newValue: string | string[];
if (multiple) {
const currentValues = Array.isArray(value) ? value : [];
if (currentValues.includes(optionValue)) {
newValue = currentValues.filter((v) => v !== optionValue);
} else {
newValue = [...currentValues, optionValue];
}
} else {
newValue = optionValue;
setIsOpen(false);
}
const syntheticEvent = {
target: {
name: name || '',
value: optionValue,
value: newValue,
},
};
onChange(syntheticEvent);
setIsOpen(false);
onChange(syntheticEvent as any);
};
const selectedOptionLabel = (() => {
if (multiple) {
if (Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
return options.find((o) => o.value === value[0])?.label || '';
}
return `${value.length} selected`;
}
return 'Select...';
}
return options.find((option) => option.value === value)?.label || 'Select...';
})();
const isSelected = (optionValue: string) => {
if (multiple && Array.isArray(value)) {
return value.includes(optionValue);
}
return optionValue === value;
};
return (
@@ -72,12 +103,13 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
key={option.value}
onClick={() => handleOptionClick(option.value)}
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
${option.value === value
? 'bg-cyan-500/20 text-cyan-300'
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
${
isSelected(option.value)
? 'bg-cyan-500/20 text-cyan-300'
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
}`}
role="option"
aria-selected={option.value === value}
aria-selected={isSelected(option.value)}
>
<span className="truncate">{option.label}</span>
</li>
@@ -86,7 +118,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
)}
{/* Hidden input to mimic native select behavior for forms */}
{name && <input type="hidden" name={name} value={value} />}
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
</div>
);
};

View File

@@ -1,48 +0,0 @@
import React, { useState, useMemo } from 'react';
import { icons } from 'lucide-react';
interface IconPickerProps {
onSelect: (iconName: string) => void;
}
const IconPicker: React.FC<IconPickerProps> = ({ onSelect }) => {
const [search, setSearch] = useState('');
const filteredIcons = useMemo(() => {
if (!search) {
return Object.keys(icons).slice(0, 50);
}
return Object.keys(icons).filter(name =>
name.toLowerCase().includes(search.toLowerCase())
);
}, [search]);
return (
<div className="bg-gray-800 p-4 rounded-lg">
<input
type="text"
placeholder="Search for an icon..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full p-2 mb-4 bg-gray-700 rounded text-white"
/>
<div className="grid grid-cols-6 gap-4 max-h-60 overflow-y-auto">
{filteredIcons.map(iconName => {
const LucideIcon = icons[iconName as keyof typeof icons];
return (
<div
key={iconName}
onClick={() => onSelect(iconName)}
className="cursor-pointer flex flex-col items-center justify-center p-2 rounded-lg hover:bg-gray-700"
>
<LucideIcon color="white" size={24} />
<span className="text-xs text-white mt-1">{iconName}</span>
</div>
);
})}
</div>
</div>
);
};
export default IconPicker;

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Website } from '../types';
import IconPicker from './IconPicker';
import { getWebsiteIcon } from './utils/iconService';
import { icons } from 'lucide-react';
interface WebsiteEditModalProps {
website?: Website;
@@ -12,21 +10,70 @@ interface WebsiteEditModalProps {
onDelete: () => void;
}
interface IconMetadata {
name: string;
base: string;
aliases: string[];
categories: string[];
update: {
timestamp: string;
author: {
id: number;
name: string;
};
};
colors: any; // this can be anything I guess
}
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : '');
const [icon, setIcon] = useState(website ? website.icon : '');
const [showIconPicker, setShowIconPicker] = useState(false);
const [iconQuery, setIconQuery] = useState('');
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
useEffect(() => {
const fetchIcon = async () => {
if (url) {
const fetchedIcon = await getWebsiteIcon(url);
setIcon(fetchedIcon);
}
};
fetchIcon();
}, [url]);
fetch('/icon-metadata.json')
.then(response => response.json())
.then(data => {
const iconsArray = Object.entries(data).map(([name, details]) => ({
name,
...details
}));
// Expand colors into separate entries
iconsArray.forEach(icon => {
if (icon.colors) {
const colors = Object.values(icon.colors).filter(key => key !== icon.name);
for (const color of colors) {
const newIcon = { ...icon };
newIcon.name = color;
iconsArray.push(newIcon);
}
}
});
setIconMetadata(iconsArray);
});
}, []);
useEffect(() => {
if (iconQuery && Array.isArray(iconMetadata)) {
const lowerCaseQuery = iconQuery.toLowerCase();
const filtered = iconMetadata
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery))
.slice(0, 50);
setFilteredIcons(filtered);
} else {
setFilteredIcons([]);
}
}, [iconQuery, iconMetadata]);
const fetchIcon = async () => {
if (url) {
const fetchedIcon = await getWebsiteIcon(url);
setIcon(fetchedIcon);
}
};
const handleSave = () => {
onSave({ id: website?.id, name, url, icon });
@@ -38,18 +85,22 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
}
};
const LucideIcon = icons[icon as keyof typeof icons];
return (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
<div className="flex flex-col gap-4">
<div className="flex justify-center mb-4">
{LucideIcon ? (
<LucideIcon className="h-24 w-24 text-white" />
) : (
{icon ? (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
) : (
<div className="h-24 w-24 bg-white/10 rounded-lg 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>
)}
</div>
<input
@@ -67,25 +118,44 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Icon URL or name"
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
/>
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
{showIconPicker ? 'Close' : 'Select Icon'}
<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);
}}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
/>
{filteredIcons.length > 0 && (
<div className="absolute z-10 w-full bg-gray-800 rounded-lg mt-1 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 hover:bg-gray-700"
>
<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="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
Fetch
</button>
</div>
{showIconPicker && (
<IconPicker
onSelect={(iconName) => {
setIcon(iconName);
setShowIconPicker(false);
}}
/>
)}
</div>
<div className="flex justify-between items-center mt-8">
<div>
@@ -109,4 +179,4 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
);
};
export default WebsiteEditModal;
export default WebsiteEditModal;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Website } from '../types';
import { icons, ArrowLeft, ArrowRight, Pencil } from 'lucide-react';
interface WebsiteTileProps {
website: Website;
@@ -37,7 +37,7 @@ const getIconSize = (size: string | undefined) => {
}
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const LucideIcon = icons[website.icon as keyof typeof icons];
const [isLoading, setIsLoading] = useState(false);
const handleClick = (e: React.MouseEvent) => {
@@ -76,11 +76,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
)}
<div className={`flex items-center transition-all duration-300 ease-in ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
<div className={`transition-all duration-300 ease-in ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
{LucideIcon ? (
<LucideIcon className={`text-white ${isLoading ? iconSizeLoadingClass : iconSizeClass}`} />
) : (
<img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
)}
<img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
</div>
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
{website.name}
@@ -89,9 +85,15 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</a>
{isEditing && (
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><ArrowLeft size={16} /></button>
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><Pencil size={16} /></button>
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><ArrowRight size={16} /></button>
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-left" viewBox="0 0 16 16">
<path fill-rule="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="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
<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="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-right" viewBox="0 0 16 16">
<path fill-rule="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>
</div>
)}
</div>