Compare commits
	
		
			3 Commits
		
	
	
		
			12ed7e1b9f
			...
			05263d0d3a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 05263d0d3a | |||
| 905b05e343 | |||
| 181fd3b3ec | 
							
								
								
									
										26
									
								
								.gitea/workflows/main.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								.gitea/workflows/main.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					name: Check scripts syntax
 | 
				
			||||||
 | 
					on: [push]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					jobs:
 | 
				
			||||||
 | 
					  build:
 | 
				
			||||||
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					    steps:
 | 
				
			||||||
 | 
					      - name: Check out repository code
 | 
				
			||||||
 | 
					        uses: actions/checkout@v4
 | 
				
			||||||
 | 
					      - name: List files in the repository
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          ls ${{ gitea.workspace }}
 | 
				
			||||||
 | 
					      - name: Install JS dependencies
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          npm install
 | 
				
			||||||
 | 
					      - name: Run scripts
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          bash download-icons.sh
 | 
				
			||||||
 | 
					      - name: Run build
 | 
				
			||||||
 | 
					        run: |
 | 
				
			||||||
 | 
					          npm run build
 | 
				
			||||||
 | 
					      - name: Release zip
 | 
				
			||||||
 | 
					        uses: akkuman/gitea-release-action@v1
 | 
				
			||||||
 | 
					        with:
 | 
				
			||||||
 | 
					          files: |-
 | 
				
			||||||
 | 
					            dist/**
 | 
				
			||||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -22,3 +22,6 @@ dist-ssr
 | 
				
			|||||||
*.njsproj
 | 
					*.njsproj
 | 
				
			||||||
*.sln
 | 
					*.sln
 | 
				
			||||||
*.sw?
 | 
					*.sw?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Project specific files
 | 
				
			||||||
 | 
					public/icon-metadata.json
 | 
				
			||||||
							
								
								
									
										224
									
								
								App.tsx
									
									
									
									
									
								
							
							
						
						
									
										224
									
								
								App.tsx
									
									
									
									
									
								
							@@ -1,21 +1,22 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					import React, { useState, useEffect } from 'react';
 | 
				
			||||||
import WebsiteTile from './components/WebsiteTile';
 | 
					 | 
				
			||||||
import ConfigurationModal from './components/ConfigurationModal';
 | 
					import ConfigurationModal from './components/ConfigurationModal';
 | 
				
			||||||
import Clock from './components/Clock';
 | 
					 | 
				
			||||||
import ServerWidget from './components/ServerWidget';
 | 
					import ServerWidget from './components/ServerWidget';
 | 
				
			||||||
import { DEFAULT_CATEGORIES } from './constants';
 | 
					import { DEFAULT_CATEGORIES } from './constants';
 | 
				
			||||||
import { Category, Website, Wallpaper } from './types';
 | 
					import { Category, Website, Wallpaper, Config } from './types';
 | 
				
			||||||
import Dropdown from './components/Dropdown';
 | 
					 | 
				
			||||||
import WebsiteEditModal from './components/WebsiteEditModal';
 | 
					import WebsiteEditModal from './components/WebsiteEditModal';
 | 
				
			||||||
import CategoryEditModal from './components/CategoryEditModal';
 | 
					import CategoryEditModal from './components/CategoryEditModal';
 | 
				
			||||||
import { PlusCircle, Pencil } from 'lucide-react';
 | 
					import Header from './components/layout/Header';
 | 
				
			||||||
 | 
					import EditButton from './components/layout/EditButton';
 | 
				
			||||||
 | 
					import ConfigurationButton from './components/layout/ConfigurationButton';
 | 
				
			||||||
 | 
					import CategoryGroup from './components/layout/CategoryGroup';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { baseWallpapers } from './components/utils/baseWallpapers';
 | 
					import { baseWallpapers } from './components/utils/baseWallpapers';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const defaultConfig: Config = {
 | 
				
			||||||
const defaultConfig = {
 | 
					 | 
				
			||||||
  title: 'Vision Start',
 | 
					  title: 'Vision Start',
 | 
				
			||||||
  subtitle: 'Your personal portal to the web.',
 | 
					  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,
 | 
					  wallpaperBlur: 0,
 | 
				
			||||||
  wallpaperBrightness: 100,
 | 
					  wallpaperBrightness: 100,
 | 
				
			||||||
  wallpaperOpacity: 100,
 | 
					  wallpaperOpacity: 100,
 | 
				
			||||||
@@ -54,11 +55,15 @@ const App: React.FC = () => {
 | 
				
			|||||||
  const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
 | 
					  const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
 | 
				
			||||||
  const [editingCategory, setEditingCategory] = useState<Category | null>(null);
 | 
					  const [editingCategory, setEditingCategory] = useState<Category | null>(null);
 | 
				
			||||||
  const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
 | 
					  const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
 | 
				
			||||||
  const [config, setConfig] = useState(() => {
 | 
					  const [config, setConfig] = useState<Config>(() => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      const storedConfig = localStorage.getItem('config');
 | 
					      const storedConfig = localStorage.getItem('config');
 | 
				
			||||||
      if (storedConfig) {
 | 
					      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) {
 | 
					    } catch (error) {
 | 
				
			||||||
      console.error('Error parsing config from localStorage', error);
 | 
					      console.error('Error parsing config from localStorage', error);
 | 
				
			||||||
@@ -69,9 +74,54 @@ const App: React.FC = () => {
 | 
				
			|||||||
    const storedUserWallpapers = localStorage.getItem('userWallpapers');
 | 
					    const storedUserWallpapers = localStorage.getItem('userWallpapers');
 | 
				
			||||||
    return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
 | 
					    return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					  const [currentWallpaper, setCurrentWallpaper] = useState<string>('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const allWallpapers = [...baseWallpapers, ...userWallpapers];
 | 
					  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(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    localStorage.setItem('categories', JSON.stringify(categories));
 | 
					    localStorage.setItem('categories', JSON.stringify(categories));
 | 
				
			||||||
@@ -191,51 +241,6 @@ const App: React.FC = () => {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  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';
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const getHorizontalAlignmentClass = (alignment: string) => {
 | 
					  const getHorizontalAlignmentClass = (alignment: string) => {
 | 
				
			||||||
    switch (alignment) {
 | 
					    switch (alignment) {
 | 
				
			||||||
      case 'left':
 | 
					      case 'left':
 | 
				
			||||||
@@ -250,107 +255,39 @@ const App: React.FC = () => {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
 | 
					 | 
				
			||||||
    <main
 | 
					    <main
 | 
				
			||||||
      className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
 | 
					      className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
 | 
					        className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
 | 
				
			||||||
        style={{
 | 
					        style={{
 | 
				
			||||||
          backgroundImage: `url('${selectedWallpaper?.url || selectedWallpaper?.base64 || ''}')`,
 | 
					          backgroundImage: `url('${currentWallpaper}')`,
 | 
				
			||||||
          filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
 | 
					          filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
 | 
				
			||||||
          opacity: `${config.wallpaperOpacity}%`,
 | 
					          opacity: `${config.wallpaperOpacity}%`,
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
      ></div>
 | 
					      ></div>
 | 
				
			||||||
      <div className="absolute top-4 left-4">
 | 
					      <EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
 | 
				
			||||||
        <button 
 | 
					      <ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
 | 
				
			||||||
          onClick={() => setIsEditing(!isEditing)} 
 | 
					 | 
				
			||||||
          className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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>
 | 
					 | 
				
			||||||
          {isEditing ? 'Done' : 'Edit'}
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
      <div className="absolute top-4 right-4">
 | 
					 | 
				
			||||||
        <button 
 | 
					 | 
				
			||||||
          onClick={() => setIsConfigModalOpen(true)} 
 | 
					 | 
				
			||||||
          className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white hover:bg-white/25 transition-colors"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-gear-wide" viewBox="0 0 16 16">
 | 
					 | 
				
			||||||
            <path d="M8.932.727c-.243-.97-1.62-.97-1.864 0l-.071.286a.96.96 0 0 1-1.622.434l-.205-.211c-.695-.719-1.888-.03-1.613.931l.08.284a.96.96 0 0 1-1.186 1.187l-.284-.081c-.96-.275-1.65.918-.931 1.613l.211.205a.96.96 0 0 1-.434 1.622l-.286.071c-.97.243-.97 1.62 0 1.864l.286.071a.96.96 0 0 1 .434 1.622l-.211.205c-.719.695-.03 1.888.931 1.613l.284-.08a.96.96 0 0 1 1.187 1.187l-.081.283c-.275.96.918 1.65 1.613.931l.205-.211a.96.96 0 0 1 1.622.434l.071.286c.243.97 1.62.97 1.864 0l.071-.286a.96.96 0 0 1 1.622-.434l.205.211c.695.719 1.888.03 1.613-.931l-.08-.284a.96.96 0 0 1 1.187-1.187l.283.081c.96.275 1.65-.918.931-1.613l-.211-.205a.96.96 0 0 1 .434-1.622l.286-.071c.97-.243.97-1.62 0-1.864l-.286-.071a.96.96 0 0 1-.434-1.622l.211-.205c.719-.695.03-1.888-.931-1.613l-.284.08a.96.96 0 0 1-1.187-1.186l.081-.284c.275-.96-.918-1.65-1.613-.931l-.205.211a.96.96 0 0 1-1.622-.434zM8 12.997a4.998 4.998 0 1 1 0-9.995 4.998 4.998 0 0 1 0 9.996z"/>
 | 
					 | 
				
			||||||
          </svg>
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {/* Absolute top-center Clock */}
 | 
					      <Header config={config} />
 | 
				
			||||||
      {config.clock.enabled && (
 | 
					 | 
				
			||||||
        <div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
 | 
					 | 
				
			||||||
          <Clock config={config} getClockSizeClass={getClockSizeClass} />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
 | 
					 | 
				
			||||||
          {(config.title || config.subtitle) && (
 | 
					 | 
				
			||||||
            <div className="text-center">
 | 
					 | 
				
			||||||
              <h1 
 | 
					 | 
				
			||||||
                className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
 | 
					 | 
				
			||||||
                style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                {config.title}
 | 
					 | 
				
			||||||
              </h1>
 | 
					 | 
				
			||||||
              <p 
 | 
					 | 
				
			||||||
                className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
 | 
					 | 
				
			||||||
                style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                {config.subtitle}
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div className="flex flex-col gap-8 w-full mt-16">
 | 
					      <div className="flex flex-col gap-8 w-full mt-16">
 | 
				
			||||||
        {categories.map((category) => (
 | 
					        {categories.map((category) => (
 | 
				
			||||||
          <div key={category.id} className="w-full">
 | 
					          <CategoryGroup
 | 
				
			||||||
            <div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
 | 
					            key={category.id}
 | 
				
			||||||
              <h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
 | 
					            category={category}
 | 
				
			||||||
              {isEditing && (
 | 
					            isEditing={isEditing}
 | 
				
			||||||
                <button
 | 
					            setEditingCategory={setEditingCategory}
 | 
				
			||||||
                  onClick={() => {
 | 
					            setIsCategoryModalOpen={setIsCategoryModalOpen}
 | 
				
			||||||
                    setEditingCategory(category);
 | 
					            setAddingWebsite={setAddingWebsite}
 | 
				
			||||||
                    setIsCategoryModalOpen(true);
 | 
					            setEditingWebsite={setEditingWebsite}
 | 
				
			||||||
                  }}
 | 
					            handleMoveWebsite={handleMoveWebsite}
 | 
				
			||||||
                  className="ml-2 text-white/50 hover:text-white transition-colors"
 | 
					            getHorizontalAlignmentClass={getHorizontalAlignmentClass}
 | 
				
			||||||
                >
 | 
					            config={config}
 | 
				
			||||||
                  <Pencil size={20} />
 | 
					          />
 | 
				
			||||||
                </button>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
 | 
					 | 
				
			||||||
              {category.websites.map((website) => (
 | 
					 | 
				
			||||||
                <WebsiteTile
 | 
					 | 
				
			||||||
                  key={website.id}
 | 
					 | 
				
			||||||
                  website={website}
 | 
					 | 
				
			||||||
                  isEditing={isEditing}
 | 
					 | 
				
			||||||
                  onEdit={setEditingWebsite}
 | 
					 | 
				
			||||||
                  onMove={handleMoveWebsite}
 | 
					 | 
				
			||||||
                  tileSize={config.tileSize}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              ))}
 | 
					 | 
				
			||||||
              {isEditing && (
 | 
					 | 
				
			||||||
                <button
 | 
					 | 
				
			||||||
                  onClick={() => setAddingWebsite(category)}
 | 
					 | 
				
			||||||
                  className="text-white/50 hover:text-white transition-colors"
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  <PlusCircle size={48} />
 | 
					 | 
				
			||||||
                </button>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        ))}
 | 
					        ))}
 | 
				
			||||||
        {isEditing && (
 | 
					        {isEditing && (
 | 
				
			||||||
          <div className="flex justify-center">
 | 
					          <div className={`flex justify-center transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
 | 
				
			||||||
            <button
 | 
					            <button
 | 
				
			||||||
              onClick={() => {
 | 
					              onClick={() => {
 | 
				
			||||||
                setEditingCategory(null);
 | 
					                setEditingCategory(null);
 | 
				
			||||||
@@ -358,7 +295,10 @@ const App: React.FC = () => {
 | 
				
			|||||||
              }}
 | 
					              }}
 | 
				
			||||||
              className="text-white/50 hover:text-white transition-colors"
 | 
					              className="text-white/50 hover:text-white transition-colors"
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <PlusCircle size={48} />
 | 
					              <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
 | 
				
			||||||
 | 
					                <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>
 | 
				
			||||||
            </button>
 | 
					            </button>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										39
									
								
								GEMINI.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								GEMINI.md
									
									
									
									
									
										Normal file
									
								
							@@ -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
 | 
				
			||||||
 | 
					    <Dropdown
 | 
				
			||||||
 | 
					      options={[
 | 
				
			||||||
 | 
					        { value: 'option1', label: 'Option 1' },
 | 
				
			||||||
 | 
					        { value: 'option2', label: 'Option 2' },
 | 
				
			||||||
 | 
					      ]}
 | 
				
			||||||
 | 
					      value={selectedValue}
 | 
				
			||||||
 | 
					      onChange={handleSelectChange}
 | 
				
			||||||
 | 
					      name="myDropdown"
 | 
				
			||||||
 | 
					    />
 | 
				
			||||||
 | 
					    ```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## 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`.
 | 
				
			||||||
@@ -15,3 +15,8 @@
 | 
				
			|||||||
   `npm install`
 | 
					   `npm install`
 | 
				
			||||||
2. Run the app:
 | 
					2. Run the app:
 | 
				
			||||||
   `npm run dev`
 | 
					   `npm run dev`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## to-do
 | 
				
			||||||
 | 
					* [] Multiple wallpapers
 | 
				
			||||||
 | 
					* [x] Remake icons
 | 
				
			||||||
 | 
					* [] Increase offline compatibility
 | 
				
			||||||
@@ -1,9 +1,8 @@
 | 
				
			|||||||
 | 
					 | 
				
			||||||
import React, { useState, useRef, useEffect } from 'react';
 | 
					import React, { useState, useRef, useEffect } from 'react';
 | 
				
			||||||
import ToggleSwitch from './ToggleSwitch';
 | 
					import ToggleSwitch from './ToggleSwitch';
 | 
				
			||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
 | 
					import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
 | 
				
			||||||
import { Server, Wallpaper } from '../types';
 | 
					import { Server, Wallpaper } from '../types';
 | 
				
			||||||
import { Trash } from 'lucide-react';
 | 
					
 | 
				
			||||||
import Dropdown from './Dropdown';
 | 
					import Dropdown from './Dropdown';
 | 
				
			||||||
import { baseWallpapers } from './utils/baseWallpapers';
 | 
					import { baseWallpapers } from './utils/baseWallpapers';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -37,6 +36,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
 | 
				
			|||||||
      format: 'h:mm A',
 | 
					      format: 'h:mm A',
 | 
				
			||||||
      ...currentConfig.clock,
 | 
					      ...currentConfig.clock,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    backgroundUrls: currentConfig.backgroundUrls || [],
 | 
				
			||||||
 | 
					    wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
  const [activeTab, setActiveTab] = useState('general');
 | 
					  const [activeTab, setActiveTab] = useState('general');
 | 
				
			||||||
  const [newServerName, setNewServerName] = useState('');
 | 
					  const [newServerName, setNewServerName] = useState('');
 | 
				
			||||||
@@ -70,7 +71,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
 | 
				
			|||||||
    }, 300); // This duration should match the transition duration
 | 
					    }, 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;
 | 
					    const { name, value } = e.target;
 | 
				
			||||||
    if (name.startsWith('serverWidget.')) {
 | 
					    if (name.startsWith('serverWidget.')) {
 | 
				
			||||||
      const field = name.split('.')[1];
 | 
					      const field = name.split('.')[1];
 | 
				
			||||||
@@ -158,7 +159,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
 | 
				
			|||||||
    const updatedUserWallpapers = [...userWallpapers, newWallpaper];
 | 
					    const updatedUserWallpapers = [...userWallpapers, newWallpaper];
 | 
				
			||||||
    setUserWallpapers(updatedUserWallpapers);
 | 
					    setUserWallpapers(updatedUserWallpapers);
 | 
				
			||||||
    localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
 | 
					    localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
 | 
				
			||||||
    setConfig({ ...config, backgroundUrl: newWallpaperUrl });
 | 
					    setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setNewWallpaperName('');
 | 
					    setNewWallpaperName('');
 | 
				
			||||||
    setNewWallpaperUrl('');
 | 
					    setNewWallpaperUrl('');
 | 
				
			||||||
@@ -187,23 +188,20 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
 | 
				
			|||||||
        };
 | 
					        };
 | 
				
			||||||
        setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
 | 
					        setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
 | 
				
			||||||
        localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
 | 
					        localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
 | 
				
			||||||
        setConfig({ ...config, backgroundUrl: base64 });
 | 
					        setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, base64] });
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
      reader.readAsDataURL(file);
 | 
					      reader.readAsDataURL(file);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
 | 
					  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);
 | 
					    setUserWallpapers(updatedUserWallpapers);
 | 
				
			||||||
    localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
 | 
					    localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (config.backgroundUrl === (wallpaper.url || wallpaper.base64)) {
 | 
					    const newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
 | 
				
			||||||
      const nextWallpaper = baseWallpapers[0] || updatedUserWallpapers[0];
 | 
					    setConfig({ ...config, backgroundUrls: newBackgroundUrls });
 | 
				
			||||||
      if (nextWallpaper) {
 | 
					 | 
				
			||||||
        setConfig({ ...config, backgroundUrl: nextWallpaper.url || nextWallpaper.base64 });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const allWallpapers = [...baseWallpapers, ...userWallpapers];
 | 
					  const allWallpapers = [...baseWallpapers, ...userWallpapers];
 | 
				
			||||||
@@ -350,23 +348,27 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
 | 
				
			|||||||
              <div className="flex items-center justify-between">
 | 
					              <div className="flex items-center justify-between">
 | 
				
			||||||
                <label className="text-slate-300 text-sm font-semibold">Background</label>
 | 
					                <label className="text-slate-300 text-sm font-semibold">Background</label>
 | 
				
			||||||
                <Dropdown
 | 
					                <Dropdown
 | 
				
			||||||
                  name="backgroundUrl"
 | 
					                  name="backgroundUrls"
 | 
				
			||||||
                  value={config.backgroundUrl}
 | 
					                  value={config.backgroundUrls}
 | 
				
			||||||
                  onChange={handleChange}
 | 
					                  onChange={handleChange}
 | 
				
			||||||
 | 
					                  multiple
 | 
				
			||||||
                  options={allWallpapers.map(w => ({ 
 | 
					                  options={allWallpapers.map(w => ({ 
 | 
				
			||||||
                    value: w.url || w.base64 || '', 
 | 
					                    value: w.url || w.base64 || '', 
 | 
				
			||||||
                    label: (
 | 
					                    label: (
 | 
				
			||||||
                      <div className="flex items-center justify-between">
 | 
					                      <div className="flex items-center justify-between w-full">
 | 
				
			||||||
                        {w.name}
 | 
					                        <span>{w.name}</span>
 | 
				
			||||||
                        {!baseWallpapers.includes(w) && (
 | 
					                        {!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && (
 | 
				
			||||||
                          <button 
 | 
					                          <button 
 | 
				
			||||||
                            onClick={(e) => {
 | 
					                            onClick={(e) => {
 | 
				
			||||||
                              e.stopPropagation();
 | 
					                              e.stopPropagation();
 | 
				
			||||||
                              handleDeleteWallpaper(w);
 | 
					                              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>
 | 
					                          </button>
 | 
				
			||||||
                        )}
 | 
					                        )}
 | 
				
			||||||
                      </div>
 | 
					                      </div>
 | 
				
			||||||
@@ -374,6 +376,24 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
 | 
				
			|||||||
                  }))}
 | 
					                  }))}
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
              </div>
 | 
					              </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">
 | 
					              <div className="flex items-center justify-between">
 | 
				
			||||||
                <label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
 | 
					                <label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
 | 
				
			||||||
                <div className="flex items-center gap-4">
 | 
					                <div className="flex items-center gap-4">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,17 +2,16 @@ import React, { useState, useRef, useEffect } from 'react';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
interface DropdownProps {
 | 
					interface DropdownProps {
 | 
				
			||||||
  options: { value: string; label: string }[];
 | 
					  options: { value: string; label: string }[];
 | 
				
			||||||
  value: string;
 | 
					  value: string | string[];
 | 
				
			||||||
  onChange: (e: { target: { name: string; value: string } }) => void;
 | 
					  onChange: (e: { target: { name: string; value: string | string[] } }) => void;
 | 
				
			||||||
  name?: string;
 | 
					  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 [isOpen, setIsOpen] = useState(false);
 | 
				
			||||||
  const dropdownRef = useRef<HTMLDivElement>(null);
 | 
					  const dropdownRef = useRef<HTMLDivElement>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const selectedOptionLabel = options.find(option => option.value === value)?.label || '';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const handleClickOutside = (event: MouseEvent) => {
 | 
					    const handleClickOutside = (event: MouseEvent) => {
 | 
				
			||||||
      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
 | 
					      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) => {
 | 
					  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 = {
 | 
					    const syntheticEvent = {
 | 
				
			||||||
      target: {
 | 
					      target: {
 | 
				
			||||||
        name: name || '',
 | 
					        name: name || '',
 | 
				
			||||||
        value: optionValue,
 | 
					        value: newValue,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    onChange(syntheticEvent);
 | 
					    onChange(syntheticEvent as any);
 | 
				
			||||||
    setIsOpen(false);
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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 (
 | 
					  return (
 | 
				
			||||||
@@ -72,12 +103,13 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
 | 
				
			|||||||
              key={option.value}
 | 
					              key={option.value}
 | 
				
			||||||
              onClick={() => handleOptionClick(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
 | 
					              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' 
 | 
					                  isSelected(option.value)
 | 
				
			||||||
                  : 'hover:bg-white/20 hover:text-white hover:shadow-lg'
 | 
					                    ? 'bg-cyan-500/20 text-cyan-300'
 | 
				
			||||||
 | 
					                    : 'hover:bg-white/20 hover:text-white hover:shadow-lg'
 | 
				
			||||||
                }`}
 | 
					                }`}
 | 
				
			||||||
              role="option"
 | 
					              role="option"
 | 
				
			||||||
              aria-selected={option.value === value}
 | 
					              aria-selected={isSelected(option.value)}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              <span className="truncate">{option.label}</span>
 | 
					              <span className="truncate">{option.label}</span>
 | 
				
			||||||
            </li>
 | 
					            </li>
 | 
				
			||||||
@@ -86,7 +118,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
 | 
				
			|||||||
      )}
 | 
					      )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {/* Hidden input to mimic native select behavior for forms */}
 | 
					      {/* 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>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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;
 | 
					 | 
				
			||||||
@@ -1,8 +1,6 @@
 | 
				
			|||||||
import React, { useState, useEffect } from 'react';
 | 
					import React, { useState, useEffect } from 'react';
 | 
				
			||||||
import { Website } from '../types';
 | 
					import { Website } from '../types';
 | 
				
			||||||
import IconPicker from './IconPicker';
 | 
					 | 
				
			||||||
import { getWebsiteIcon } from './utils/iconService';
 | 
					import { getWebsiteIcon } from './utils/iconService';
 | 
				
			||||||
import { icons } from 'lucide-react';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface WebsiteEditModalProps {
 | 
					interface WebsiteEditModalProps {
 | 
				
			||||||
  website?: Website;
 | 
					  website?: Website;
 | 
				
			||||||
@@ -12,21 +10,70 @@ interface WebsiteEditModalProps {
 | 
				
			|||||||
  onDelete: () => void;
 | 
					  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 WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
 | 
				
			||||||
  const [name, setName] = useState(website ? website.name : '');
 | 
					  const [name, setName] = useState(website ? website.name : '');
 | 
				
			||||||
  const [url, setUrl] = useState(website ? website.url : '');
 | 
					  const [url, setUrl] = useState(website ? website.url : '');
 | 
				
			||||||
  const [icon, setIcon] = useState(website ? website.icon : '');
 | 
					  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(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    const fetchIcon = async () => {
 | 
					    fetch('/icon-metadata.json')
 | 
				
			||||||
      if (url) {
 | 
					      .then(response => response.json())
 | 
				
			||||||
        const fetchedIcon = await getWebsiteIcon(url);
 | 
					      .then(data => {
 | 
				
			||||||
        setIcon(fetchedIcon);
 | 
					        const iconsArray = Object.entries(data).map(([name, details]) => ({
 | 
				
			||||||
      }
 | 
					          name,
 | 
				
			||||||
    };
 | 
					          ...details
 | 
				
			||||||
    fetchIcon();
 | 
					        }));
 | 
				
			||||||
  }, [url]);
 | 
					        // 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 = () => {
 | 
					  const handleSave = () => {
 | 
				
			||||||
    onSave({ id: website?.id, name, url, icon });
 | 
					    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 (
 | 
					  return (
 | 
				
			||||||
    <div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
 | 
					    <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">
 | 
					      <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>
 | 
					        <h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
 | 
				
			||||||
        <div className="flex flex-col gap-4">
 | 
					        <div className="flex flex-col gap-4">
 | 
				
			||||||
          <div className="flex justify-center mb-4">
 | 
					          <div className="flex justify-center mb-4">
 | 
				
			||||||
            {LucideIcon ? (
 | 
					            {icon ? (
 | 
				
			||||||
              <LucideIcon className="h-24 w-24 text-white" />
 | 
					 | 
				
			||||||
            ) : (
 | 
					 | 
				
			||||||
              <img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
 | 
					              <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>
 | 
					          </div>
 | 
				
			||||||
          <input
 | 
					          <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"
 | 
					            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">
 | 
					          <div className="flex items-center gap-2">
 | 
				
			||||||
            <input
 | 
					            <div className="relative w-full">
 | 
				
			||||||
              type="text"
 | 
					              <input
 | 
				
			||||||
              placeholder="Icon URL or name"
 | 
					                type="text"
 | 
				
			||||||
              value={icon}
 | 
					                placeholder="Icon URL or name"
 | 
				
			||||||
              onChange={(e) => setIcon(e.target.value)}
 | 
					                value={icon}
 | 
				
			||||||
              className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
 | 
					                onChange={(e) => {
 | 
				
			||||||
            />
 | 
					                  setIcon(e.target.value);
 | 
				
			||||||
            <button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
 | 
					                  setIconQuery(e.target.value);
 | 
				
			||||||
              {showIconPicker ? 'Close' : 'Select Icon'}
 | 
					                }}
 | 
				
			||||||
 | 
					                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>
 | 
					            </button>
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          {showIconPicker && (
 | 
					 | 
				
			||||||
            <IconPicker
 | 
					 | 
				
			||||||
              onSelect={(iconName) => {
 | 
					 | 
				
			||||||
                setIcon(iconName);
 | 
					 | 
				
			||||||
                setShowIconPicker(false);
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div className="flex justify-between items-center mt-8">
 | 
					        <div className="flex justify-between items-center mt-8">
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
import React, { useState } from 'react';
 | 
					import React, { useState } from 'react';
 | 
				
			||||||
import { Website } from '../types';
 | 
					import { Website } from '../types';
 | 
				
			||||||
import { icons, ArrowLeft, ArrowRight, Pencil } from 'lucide-react';
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface WebsiteTileProps {
 | 
					interface WebsiteTileProps {
 | 
				
			||||||
  website: Website;
 | 
					  website: Website;
 | 
				
			||||||
@@ -37,7 +37,7 @@ const getIconSize = (size: string | undefined) => {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
 | 
					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 [isLoading, setIsLoading] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleClick = (e: React.MouseEvent) => {
 | 
					  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={`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}`}>
 | 
					          <div className={`transition-all duration-300 ease-in ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
 | 
				
			||||||
            {LucideIcon ? (
 | 
					            <img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
 | 
				
			||||||
              <LucideIcon className={`text-white ${isLoading ? iconSizeLoadingClass : iconSizeClass}`} />
 | 
					 | 
				
			||||||
            ) : (
 | 
					 | 
				
			||||||
              <img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
 | 
					 | 
				
			||||||
            )}
 | 
					 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
 | 
					          <span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
 | 
				
			||||||
            {website.name}
 | 
					            {website.name}
 | 
				
			||||||
@@ -89,9 +85,15 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
 | 
				
			|||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
      {isEditing && (
 | 
					      {isEditing && (
 | 
				
			||||||
        <div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
 | 
					        <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={() => 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">
 | 
				
			||||||
          <button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><Pencil size={16} /></button>
 | 
					  <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"/>
 | 
				
			||||||
          <button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><ArrowRight size={16} /></button>
 | 
					</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>
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										76
									
								
								components/layout/CategoryGroup.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								components/layout/CategoryGroup.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
				
			|||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import WebsiteTile from '../WebsiteTile';
 | 
				
			||||||
 | 
					import { Category, Website } from '../../types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface CategoryGroupProps {
 | 
				
			||||||
 | 
					  category: Category;
 | 
				
			||||||
 | 
					  isEditing: boolean;
 | 
				
			||||||
 | 
					  setEditingCategory: (category: Category) => void;
 | 
				
			||||||
 | 
					  setIsCategoryModalOpen: (isOpen: boolean) => void;
 | 
				
			||||||
 | 
					  setAddingWebsite: (category: Category) => void;
 | 
				
			||||||
 | 
					  setEditingWebsite: (website: Website) => void;
 | 
				
			||||||
 | 
					  handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
 | 
				
			||||||
 | 
					  getHorizontalAlignmentClass: (alignment: string) => string;
 | 
				
			||||||
 | 
					  config: {
 | 
				
			||||||
 | 
					    horizontalAlignment: string;
 | 
				
			||||||
 | 
					    tileSize?: string;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const CategoryGroup: React.FC<CategoryGroupProps> = ({
 | 
				
			||||||
 | 
					  category,
 | 
				
			||||||
 | 
					  isEditing,
 | 
				
			||||||
 | 
					  setEditingCategory,
 | 
				
			||||||
 | 
					  setIsCategoryModalOpen,
 | 
				
			||||||
 | 
					  setAddingWebsite,
 | 
				
			||||||
 | 
					  setEditingWebsite,
 | 
				
			||||||
 | 
					  handleMoveWebsite,
 | 
				
			||||||
 | 
					  getHorizontalAlignmentClass,
 | 
				
			||||||
 | 
					  config,
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div key={category.id} className="w-full">
 | 
				
			||||||
 | 
					      <div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
 | 
				
			||||||
 | 
					        <h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
 | 
				
			||||||
 | 
					        {isEditing && (
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            onClick={() => {
 | 
				
			||||||
 | 
					              setEditingCategory(category);
 | 
				
			||||||
 | 
					              setIsCategoryModalOpen(true);
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					            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'}`}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zM1.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>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
 | 
				
			||||||
 | 
					        {category.websites.map((website) => (
 | 
				
			||||||
 | 
					          <WebsiteTile
 | 
				
			||||||
 | 
					            key={website.id}
 | 
				
			||||||
 | 
					            website={website}
 | 
				
			||||||
 | 
					            isEditing={isEditing}
 | 
				
			||||||
 | 
					            onEdit={setEditingWebsite}
 | 
				
			||||||
 | 
					            onMove={handleMoveWebsite}
 | 
				
			||||||
 | 
					            tileSize={config.tileSize}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
 | 
					        {isEditing && (
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            onClick={() => setAddingWebsite(category)}
 | 
				
			||||||
 | 
					            className={`text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
 | 
				
			||||||
 | 
					              <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>
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default CategoryGroup;
 | 
				
			||||||
							
								
								
									
										22
									
								
								components/layout/ConfigurationButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								components/layout/ConfigurationButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface ConfigurationButtonProps {
 | 
				
			||||||
 | 
					  onClick: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="absolute top-4 right-4">
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        onClick={onClick}
 | 
				
			||||||
 | 
					        className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-gear-wide" viewBox="0 0 16 16">
 | 
				
			||||||
 | 
					          <path d="M8.932.727c-.243-.97-1.62-.97-1.864 0l-.071.286a.96.96 0 0 1-1.622.434l-.205-.211c-.695-.719-1.888-.03-1.613.931l.08.284a.96.96 0 0 1-1.186 1.187l-.284-.081c-.96-.275-1.65.918-.931 1.613l.211.205a.96.96 0 0 1-.434 1.622l-.286.071c-.97.243-.97 1.62 0 1.864l.286.071a.96.96 0 0 1 .434 1.622l-.211.205c-.719.695-.03 1.888.931 1.613l.284-.08a.96.96 0 0 1 1.187 1.187l-.081.283c-.275.96.918 1.65 1.613.931l.205-.211a.96.96 0 0 1 1.622.434l.071.286c.243.97 1.62.97 1.864 0l.071-.286a.96.96 0 0 1 1.622-.434l.205.211c.695.719 1.888.03 1.613-.931l-.08-.284a.96.96 0 0 1 1.187-1.187l.283.081c.96.275 1.65-.918-.931-1.613l-.211-.205a.96.96 0 0 1 .434-1.622l.286-.071c.97-.243.97-1.62 0-1.864l-.286-.071a.96.96 0 0 1-.434-1.622l.211-.205c.719-.695.03-1.888-.931-1.613l-.284.08a.96.96 0 0 1-1.187-1.186l.081-.284c.275-.96-.918-1.65-1.613-.931l-.205.211a.96.96 0 0 1-1.622-.434zM8 12.997a4.998 4.998 0 1 1 0-9.995 4.998 4.998 0 0 1 0 9.996z"/>
 | 
				
			||||||
 | 
					        </svg>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default ConfigurationButton;
 | 
				
			||||||
							
								
								
									
										25
									
								
								components/layout/EditButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								components/layout/EditButton.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
				
			|||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface EditButtonProps {
 | 
				
			||||||
 | 
					  isEditing: boolean;
 | 
				
			||||||
 | 
					  onClick: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="absolute top-4 left-4">
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        onClick={onClick}
 | 
				
			||||||
 | 
					        className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
 | 
				
			||||||
 | 
					        style={{ fontSize: '12px' }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <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>
 | 
				
			||||||
 | 
					        {isEditing ? 'Done' : ''}
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default EditButton;
 | 
				
			||||||
							
								
								
									
										84
									
								
								components/layout/Header.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								components/layout/Header.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
				
			|||||||
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import Clock from '../Clock';
 | 
				
			||||||
 | 
					import { Config } from '../../types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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';
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 p-2">
 | 
				
			||||||
 | 
					          <Clock config={config} getClockSizeClass={getClockSizeClass} />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      <div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
 | 
				
			||||||
 | 
					        {(config.title || config.subtitle) && (
 | 
				
			||||||
 | 
					          <div className="text-center">
 | 
				
			||||||
 | 
					            <h1
 | 
				
			||||||
 | 
					              className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
 | 
				
			||||||
 | 
					              style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {config.title}
 | 
				
			||||||
 | 
					            </h1>
 | 
				
			||||||
 | 
					            <p
 | 
				
			||||||
 | 
					              className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
 | 
				
			||||||
 | 
					              style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {config.subtitle}
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default Header;
 | 
				
			||||||
							
								
								
									
										1
									
								
								download-icons.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								download-icons.sh
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					wget https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json
 | 
				
			||||||
							
								
								
									
										
											BIN
										
									
								
								icon.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								icon.png
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 763 KiB  | 
							
								
								
									
										10
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -10,7 +10,6 @@
 | 
				
			|||||||
      "dependencies": {
 | 
					      "dependencies": {
 | 
				
			||||||
        "@hello-pangea/dnd": "^18.0.1",
 | 
					        "@hello-pangea/dnd": "^18.0.1",
 | 
				
			||||||
        "@tailwindcss/vite": "^4.1.11",
 | 
					        "@tailwindcss/vite": "^4.1.11",
 | 
				
			||||||
        "lucide-react": "^0.525.0",
 | 
					 | 
				
			||||||
        "react": "^19.1.0",
 | 
					        "react": "^19.1.0",
 | 
				
			||||||
        "react-dom": "^19.1.0"
 | 
					        "react-dom": "^19.1.0"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
@@ -2031,15 +2030,6 @@
 | 
				
			|||||||
      "dev": true,
 | 
					      "dev": true,
 | 
				
			||||||
      "license": "ISC"
 | 
					      "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": {
 | 
					    "node_modules/magic-string": {
 | 
				
			||||||
      "version": "0.30.17",
 | 
					      "version": "0.30.17",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,6 @@
 | 
				
			|||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@hello-pangea/dnd": "^18.0.1",
 | 
					    "@hello-pangea/dnd": "^18.0.1",
 | 
				
			||||||
    "@tailwindcss/vite": "^4.1.11",
 | 
					    "@tailwindcss/vite": "^4.1.11",
 | 
				
			||||||
    "lucide-react": "^0.525.0",
 | 
					 | 
				
			||||||
    "react": "^19.1.0",
 | 
					    "react": "^19.1.0",
 | 
				
			||||||
    "react-dom": "^19.1.0"
 | 
					    "react-dom": "^19.1.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								public/favicon.ico
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/favicon.ico
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 763 KiB  | 
							
								
								
									
										27
									
								
								types.ts
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								types.ts
									
									
									
									
									
								
							@@ -1,4 +1,3 @@
 | 
				
			|||||||
 | 
					 | 
				
			||||||
export interface Website {
 | 
					export interface Website {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
@@ -24,3 +23,29 @@ export interface Wallpaper {
 | 
				
			|||||||
  url?: string;
 | 
					  url?: string;
 | 
				
			||||||
  base64?: string;
 | 
					  base64?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface Config {
 | 
				
			||||||
 | 
					  title: string;
 | 
				
			||||||
 | 
					  subtitle: string;
 | 
				
			||||||
 | 
					  backgroundUrls: string[];
 | 
				
			||||||
 | 
					  wallpaperFrequency: string;
 | 
				
			||||||
 | 
					  wallpaperBlur: number;
 | 
				
			||||||
 | 
					  wallpaperBrightness: number;
 | 
				
			||||||
 | 
					  wallpaperOpacity: number;
 | 
				
			||||||
 | 
					  titleSize: string;
 | 
				
			||||||
 | 
					  subtitleSize: string;
 | 
				
			||||||
 | 
					  alignment: string;
 | 
				
			||||||
 | 
					  horizontalAlignment: string;
 | 
				
			||||||
 | 
					  clock: {
 | 
				
			||||||
 | 
					    enabled: boolean;
 | 
				
			||||||
 | 
					    size: string;
 | 
				
			||||||
 | 
					    font: string;
 | 
				
			||||||
 | 
					    format: string;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  serverWidget: {
 | 
				
			||||||
 | 
					    enabled: boolean;
 | 
				
			||||||
 | 
					    pingFrequency: number;
 | 
				
			||||||
 | 
					    servers: Server[];
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  tileSize?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user