diff --git a/.gitignore b/.gitignore index a547bf3..0329c80 100755 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Project specific files +public/icon-metadata.json \ No newline at end of file diff --git a/App.tsx b/App.tsx index 33bbc6c..338790e 100755 --- a/App.tsx +++ b/App.tsx @@ -8,14 +8,14 @@ import { Category, Website, Wallpaper } from './types'; import Dropdown from './components/Dropdown'; import WebsiteEditModal from './components/WebsiteEditModal'; import CategoryEditModal from './components/CategoryEditModal'; -import { PlusCircle, Pencil } from 'lucide-react'; -import { baseWallpapers } from './components/utils/baseWallpapers'; +import { baseWallpapers } from './components/utils/baseWallpapers'; const defaultConfig = { title: 'Vision Start', subtitle: 'Your personal portal to the web.', - backgroundUrl: 'https://i.imgur.com/C6ynAtX.jpeg', + backgroundUrls: ['https://i.imgur.com/C6ynAtX.jpeg'], + wallpaperFrequency: '1d', wallpaperBlur: 0, wallpaperBrightness: 100, wallpaperOpacity: 100, @@ -58,7 +58,11 @@ const App: React.FC = () => { try { const storedConfig = localStorage.getItem('config'); if (storedConfig) { - return { ...defaultConfig, ...JSON.parse(storedConfig) }; + const parsedConfig = JSON.parse(storedConfig); + if (!parsedConfig.backgroundUrls) { + parsedConfig.backgroundUrls = [parsedConfig.backgroundUrl].filter(Boolean); + } + return { ...defaultConfig, ...parsedConfig }; } } catch (error) { console.error('Error parsing config from localStorage', error); @@ -69,9 +73,54 @@ const App: React.FC = () => { const storedUserWallpapers = localStorage.getItem('userWallpapers'); return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : []; }); + const [currentWallpaper, setCurrentWallpaper] = useState(''); const allWallpapers = [...baseWallpapers, ...userWallpapers]; - const selectedWallpaper = allWallpapers.find(w => w.url === config.backgroundUrl || w.base64 === config.backgroundUrl); + + useEffect(() => { + const getFrequencyInMs = (frequency: string) => { + const value = parseInt(frequency.slice(0, -1)); + const unit = frequency.slice(-1); + if (unit === 'h') return value * 60 * 60 * 1000; + if (unit === 'd') return value * 24 * 60 * 60 * 1000; + return 24 * 60 * 60 * 1000; // Default to 1 day + }; + + const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}'); + const lastChanged = wallpaperState.lastChanged ? new Date(wallpaperState.lastChanged).getTime() : 0; + const frequency = getFrequencyInMs(config.wallpaperFrequency); + + const updateWallpaper = () => { + const availableWallpapers = allWallpapers.filter(w => config.backgroundUrls.includes(w.url || w.base64)); + if (availableWallpapers.length > 0) { + const currentIndex = availableWallpapers.findIndex(w => (w.url || w.base64) === wallpaperState.current); + const nextIndex = (currentIndex + 1) % availableWallpapers.length; + const newWallpaper = availableWallpapers[nextIndex]; + const newWallpaperUrl = newWallpaper.url || newWallpaper.base64; + setCurrentWallpaper(newWallpaperUrl || ''); + localStorage.setItem('wallpaperState', JSON.stringify({ current: newWallpaper.name, lastChanged: new Date().toISOString() })); + } else { + setCurrentWallpaper(''); + } + }; + + if (Date.now() - lastChanged > frequency) { + updateWallpaper(); + } else { + const currentWallpaperName = wallpaperState.current; + const wallpaper = allWallpapers.find(w => w.name === currentWallpaperName); + if (wallpaper) { + setCurrentWallpaper(wallpaper.url || wallpaper.base64 || ''); + } else { + const firstWallpaperUrl = config.backgroundUrls[0] || ''; + const firstWallpaper = allWallpapers.find(w => (w.url || w.base64) === firstWallpaperUrl); + setCurrentWallpaper(firstWallpaperUrl); + if (firstWallpaper) { + localStorage.setItem('wallpaperState', JSON.stringify({ current: firstWallpaper.name, lastChanged: new Date().toISOString() })); + } + } + } + }, [config.backgroundUrls, config.wallpaperFrequency, allWallpapers]); useEffect(() => { localStorage.setItem('categories', JSON.stringify(categories)); @@ -257,7 +306,7 @@ const App: React.FC = () => {
{
@@ -321,9 +371,11 @@ const App: React.FC = () => { setEditingCategory(category); setIsCategoryModalOpen(true); }} - className="ml-2 text-white/50 hover:text-white transition-colors" + className={`ml-2 text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`} > - + + + )} @@ -341,16 +393,19 @@ const App: React.FC = () => { {isEditing && ( )} ))} {isEditing && ( -
+
)} @@ -407,4 +465,4 @@ const App: React.FC = () => { ); } -export default App; +export default App; \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..76c0d9c --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,39 @@ +# Vision Startpage Project + +## Overview + +This project is a highly customizable and stylish startpage built with React. The goal is to create a visually appealing and functional dashboard that serves as a user's entry point to the web. + +## Key Features & Design Principles + +* **Technology Stack:** The project is built using React and TypeScript. +* **Aesthetics:** The user interface should have a modern, "glassy" or "frosted glass" look (neumorphism/glassmorphism). This involves using transparency, blur effects, and subtle shadows to create a sense of depth. +* **Typography:** Specific font families and types will be used to maintain a consistent and elegant design. +* **Modals:** All modals in the application should follow a specific and consistent design language, contributing to the overall user experience. +* **Production Quality Code:** All code must be written to production standards, with a strong emphasis on readability, maintainability, and performance. +* **Creative & Beautiful Code:** Code should not only be functional but also well-structured, elegant, and creative. + +* **Dropdown Component:** A reusable dropdown component (`components/Dropdown.tsx`) has been created for consistent styling and functionality across the application. It features a dark, glassy look with a custom arrow icon. + + **Usage Example:** + ```typescript jsx + import Dropdown from './components/Dropdown'; + + // ... inside a React component + + ``` + +## Development Guidelines + +* Follow the existing code style and conventions. +* Ensure all new components and features align with the established design principles. +* Write clean, commented, and reusable code. +* DO NOT run `npm run dev`, and instead, run `npm run build`. diff --git a/README.md b/README.md index bafbc1c..c82ed30 100755 --- a/README.md +++ b/README.md @@ -15,3 +15,8 @@ `npm install` 2. Run the app: `npm run dev` + +## to-do +* [] Multiple wallpapers +* [x] Remake icons +* [] Increase offline compatibility \ No newline at end of file diff --git a/components/ConfigurationModal.tsx b/components/ConfigurationModal.tsx index a4fcbcb..9622fdf 100644 --- a/components/ConfigurationModal.tsx +++ b/components/ConfigurationModal.tsx @@ -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 = ({ 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 = ({ onClose, onSave }, 300); // This duration should match the transition duration }; - const handleChange = (e: React.ChangeEvent) => { + const handleChange = (e: React.ChangeEvent | { 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 = ({ 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 = ({ 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 = ({ onClose, onSave
({ value: w.url || w.base64 || '', label: ( -
- {w.name} - {!baseWallpapers.includes(w) && ( +
+ {w.name} + {!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && ( )}
@@ -374,6 +376,24 @@ const ConfigurationModal: React.FC = ({ onClose, onSave }))} />
+ {Array.isArray(config.backgroundUrls) && config.backgroundUrls.length > 1 && ( +
+ + +
+ )}
@@ -622,4 +642,4 @@ const ConfigurationModal: React.FC = ({ onClose, onSave ); }; -export default ConfigurationModal; +export default ConfigurationModal; \ No newline at end of file diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx index 5342f8f..e44e311 100644 --- a/components/Dropdown.tsx +++ b/components/Dropdown.tsx @@ -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 = ({ options, value, onChange, name, ...rest }) => { +const Dropdown: React.FC = ({ options, value, onChange, name, multiple = false, ...rest }) => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(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 = ({ 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 = ({ 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)} > {option.label} @@ -86,7 +118,7 @@ const Dropdown: React.FC = ({ options, value, onChange, name, ... )} {/* Hidden input to mimic native select behavior for forms */} - {name && } + {name && !multiple && }
); }; diff --git a/components/IconPicker.tsx b/components/IconPicker.tsx deleted file mode 100644 index d8e2d63..0000000 --- a/components/IconPicker.tsx +++ /dev/null @@ -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 = ({ 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 ( -
- setSearch(e.target.value)} - className="w-full p-2 mb-4 bg-gray-700 rounded text-white" - /> -
- {filteredIcons.map(iconName => { - const LucideIcon = icons[iconName as keyof typeof icons]; - return ( -
onSelect(iconName)} - className="cursor-pointer flex flex-col items-center justify-center p-2 rounded-lg hover:bg-gray-700" - > - - {iconName} -
- ); - })} -
-
- ); -}; - -export default IconPicker; diff --git a/components/WebsiteEditModal.tsx b/components/WebsiteEditModal.tsx index 29e9538..c4c02d3 100644 --- a/components/WebsiteEditModal.tsx +++ b/components/WebsiteEditModal.tsx @@ -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 = ({ 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([]); + const [filteredIcons, setFilteredIcons] = useState([]); 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 = ({ website, edit, onCl } }; - const LucideIcon = icons[icon as keyof typeof icons]; - return (

{edit ? 'Edit Website' : 'Add Website'}

- {LucideIcon ? ( - - ) : ( + {icon ? ( Website Icon + ) : ( +
+ + + + + +
)}
= ({ website, edit, onCl className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400" />
- setIcon(e.target.value)} - className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full" - /> -
- {showIconPicker && ( - { - setIcon(iconName); - setShowIconPicker(false); - }} - /> - )}
@@ -109,4 +179,4 @@ const WebsiteEditModal: React.FC = ({ website, edit, onCl ); }; -export default WebsiteEditModal; +export default WebsiteEditModal; \ No newline at end of file diff --git a/components/WebsiteTile.tsx b/components/WebsiteTile.tsx index e90966c..2ef4e3c 100755 --- a/components/WebsiteTile.tsx +++ b/components/WebsiteTile.tsx @@ -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 = ({ 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 = ({ website, isEditing, onEdit, o )}
- {LucideIcon ? ( - - ) : ( - {`${website.name} - )} + {`${website.name}
{website.name} @@ -89,9 +85,15 @@ const WebsiteTile: React.FC = ({ website, isEditing, onEdit, o {isEditing && (
- - - + + +
)}
diff --git a/download-icons.sh b/download-icons.sh new file mode 100644 index 0000000..caffeeb --- /dev/null +++ b/download-icons.sh @@ -0,0 +1 @@ +wget https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 784acb9..400de2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "@tailwindcss/vite": "^4.1.11", - "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, @@ -2031,15 +2030,6 @@ "dev": true, "license": "ISC" }, - "node_modules/lucide-react": { - "version": "0.525.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", - "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/package.json b/package.json index b42da02..c39565e 100755 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "dependencies": { "@hello-pangea/dnd": "^18.0.1", "@tailwindcss/vite": "^4.1.11", - "lucide-react": "^0.525.0", "react": "^19.1.0", "react-dom": "^19.1.0" },