Compare commits
6 Commits
d30fd8d30b
...
latest
Author | SHA1 | Date | |
---|---|---|---|
ffdaf06d55 | |||
05263d0d3a | |||
905b05e343 | |||
181fd3b3ec | |||
12ed7e1b9f | |||
e6bc95b7e6 |
29
.gitea/workflows/main.yaml
Normal file
29
.gitea/workflows/main.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Check scripts syntax
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup zip
|
||||
run: |
|
||||
sudo apt-get install zip -y
|
||||
- name: Install JS dependencies
|
||||
run: |
|
||||
npm install
|
||||
- name: Run scripts
|
||||
run: |
|
||||
bash download-icons.sh
|
||||
- name: Run build
|
||||
run: |
|
||||
npm run build
|
||||
zip -r vision-start.zip dist
|
||||
- name: Release zip
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
name: latest
|
||||
tag_name: latest
|
||||
files: |-
|
||||
vision-start.zip
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Project specific files
|
||||
public/icon-metadata.json
|
240
App.tsx
240
App.tsx
@@ -1,27 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import WebsiteTile from './components/WebsiteTile';
|
||||
import ConfigurationModal from './components/ConfigurationModal';
|
||||
import Clock from './components/Clock';
|
||||
import ServerWidget from './components/ServerWidget';
|
||||
import { DEFAULT_CATEGORIES } from './constants';
|
||||
import { Category, Website, Wallpaper } from './types';
|
||||
import Dropdown from './components/Dropdown';
|
||||
import { Category, Website, Wallpaper, Config } from './types';
|
||||
import WebsiteEditModal from './components/WebsiteEditModal';
|
||||
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';
|
||||
|
||||
|
||||
const defaultConfig = {
|
||||
const defaultConfig: Config = {
|
||||
title: 'Vision Start',
|
||||
subtitle: 'Your personal portal to the web.',
|
||||
backgroundUrl: '/waves.jpg',
|
||||
backgroundUrls: ['https://i.imgur.com/C6ynAtX.jpeg'],
|
||||
wallpaperFrequency: '1d',
|
||||
wallpaperBlur: 0,
|
||||
wallpaperBrightness: 100,
|
||||
wallpaperOpacity: 100,
|
||||
titleSize: 'medium',
|
||||
subtitleSize: 'medium',
|
||||
alignment: 'middle',
|
||||
horizontalAlignment: 'middle',
|
||||
clock: {
|
||||
enabled: true,
|
||||
size: 'medium',
|
||||
@@ -53,11 +55,15 @@ const App: React.FC = () => {
|
||||
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||
const [config, setConfig] = useState(() => {
|
||||
const [config, setConfig] = useState<Config>(() => {
|
||||
try {
|
||||
const storedConfig = localStorage.getItem('config');
|
||||
if (storedConfig) {
|
||||
return { ...defaultConfig, ...JSON.parse(storedConfig) };
|
||||
const parsedConfig = JSON.parse(storedConfig);
|
||||
if (!parsedConfig.backgroundUrls) {
|
||||
parsedConfig.backgroundUrls = [parsedConfig.backgroundUrl].filter(Boolean);
|
||||
}
|
||||
return { ...defaultConfig, ...parsedConfig };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing config from localStorage', error);
|
||||
@@ -68,9 +74,54 @@ const App: React.FC = () => {
|
||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
||||
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
|
||||
});
|
||||
const [currentWallpaper, setCurrentWallpaper] = useState<string>('');
|
||||
|
||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||
const selectedWallpaper = allWallpapers.find(w => w.url === config.backgroundUrl || w.base64 === config.backgroundUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const getFrequencyInMs = (frequency: string) => {
|
||||
const value = parseInt(frequency.slice(0, -1));
|
||||
const unit = frequency.slice(-1);
|
||||
if (unit === 'h') return value * 60 * 60 * 1000;
|
||||
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
||||
return 24 * 60 * 60 * 1000; // Default to 1 day
|
||||
};
|
||||
|
||||
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||
const lastChanged = wallpaperState.lastChanged ? new Date(wallpaperState.lastChanged).getTime() : 0;
|
||||
const frequency = getFrequencyInMs(config.wallpaperFrequency);
|
||||
|
||||
const updateWallpaper = () => {
|
||||
const availableWallpapers = allWallpapers.filter(w => config.backgroundUrls.includes(w.url || w.base64));
|
||||
if (availableWallpapers.length > 0) {
|
||||
const currentIndex = availableWallpapers.findIndex(w => (w.url || w.base64) === wallpaperState.current);
|
||||
const nextIndex = (currentIndex + 1) % availableWallpapers.length;
|
||||
const newWallpaper = availableWallpapers[nextIndex];
|
||||
const newWallpaperUrl = newWallpaper.url || newWallpaper.base64;
|
||||
setCurrentWallpaper(newWallpaperUrl || '');
|
||||
localStorage.setItem('wallpaperState', JSON.stringify({ current: newWallpaper.name, lastChanged: new Date().toISOString() }));
|
||||
} else {
|
||||
setCurrentWallpaper('');
|
||||
}
|
||||
};
|
||||
|
||||
if (Date.now() - lastChanged > frequency) {
|
||||
updateWallpaper();
|
||||
} else {
|
||||
const currentWallpaperName = wallpaperState.current;
|
||||
const wallpaper = allWallpapers.find(w => w.name === currentWallpaperName);
|
||||
if (wallpaper) {
|
||||
setCurrentWallpaper(wallpaper.url || wallpaper.base64 || '');
|
||||
} else {
|
||||
const firstWallpaperUrl = config.backgroundUrls[0] || '';
|
||||
const firstWallpaper = allWallpapers.find(w => (w.url || w.base64) === firstWallpaperUrl);
|
||||
setCurrentWallpaper(firstWallpaperUrl);
|
||||
if (firstWallpaper) {
|
||||
localStorage.setItem('wallpaperState', JSON.stringify({ current: firstWallpaper.name, lastChanged: new Date().toISOString() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [config.backgroundUrls, config.wallpaperFrequency, allWallpapers]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('categories', JSON.stringify(categories));
|
||||
@@ -190,167 +241,53 @@ 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';
|
||||
const getHorizontalAlignmentClass = (alignment: string) => {
|
||||
switch (alignment) {
|
||||
case 'left':
|
||||
return 'justify-start';
|
||||
case 'middle':
|
||||
return 'justify-center';
|
||||
case 'right':
|
||||
return 'justify-end';
|
||||
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 getTileSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-28 h-28';
|
||||
case 'medium':
|
||||
return 'w-32 h-32';
|
||||
case 'large':
|
||||
return 'w-36 h-36';
|
||||
default:
|
||||
return 'w-32 h-32';
|
||||
return 'justify-center';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<main
|
||||
className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
|
||||
style={{
|
||||
backgroundImage: `url('${selectedWallpaper?.url || selectedWallpaper?.base64 || ''}')`,
|
||||
backgroundImage: `url('${currentWallpaper}')`,
|
||||
filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
|
||||
opacity: `${config.wallpaperOpacity}%`,
|
||||
}}
|
||||
></div>
|
||||
<div className="absolute top-4 left-4">
|
||||
<button
|
||||
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>
|
||||
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
|
||||
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
|
||||
|
||||
{/* Absolute top-center Clock */}
|
||||
{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>
|
||||
<Header config={config} />
|
||||
|
||||
<div className="flex flex-col gap-8 w-full mt-16">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="w-full">
|
||||
<div className="flex justify-center items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-white text-center">{category.name}</h2>
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCategory(category);
|
||||
setIsCategoryModalOpen(true);
|
||||
}}
|
||||
className="ml-2 text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{category.websites.map((website) => (
|
||||
<WebsiteTile
|
||||
key={website.id}
|
||||
website={website}
|
||||
<CategoryGroup
|
||||
key={category.id}
|
||||
category={category}
|
||||
isEditing={isEditing}
|
||||
onEdit={setEditingWebsite}
|
||||
onMove={handleMoveWebsite}
|
||||
className={getTileSizeClass(config.tileSize)}
|
||||
setEditingCategory={setEditingCategory}
|
||||
setIsCategoryModalOpen={setIsCategoryModalOpen}
|
||||
setAddingWebsite={setAddingWebsite}
|
||||
setEditingWebsite={setEditingWebsite}
|
||||
handleMoveWebsite={handleMoveWebsite}
|
||||
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
||||
config={config}
|
||||
/>
|
||||
))}
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => setAddingWebsite(category)}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<PlusCircle size={48} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{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
|
||||
onClick={() => {
|
||||
setEditingCategory(null);
|
||||
@@ -358,7 +295,10 @@ const App: React.FC = () => {
|
||||
}}
|
||||
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>
|
||||
</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`
|
||||
2. Run the app:
|
||||
`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 ToggleSwitch from './ToggleSwitch';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { Server, Wallpaper } from '../types';
|
||||
import { Trash } from 'lucide-react';
|
||||
|
||||
import Dropdown from './Dropdown';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
|
||||
@@ -20,6 +19,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
subtitleSize: currentConfig.subtitleSize || 'medium',
|
||||
alignment: currentConfig.alignment || 'middle',
|
||||
tileSize: currentConfig.tileSize || 'medium',
|
||||
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
|
||||
wallpaperBlur: currentConfig.wallpaperBlur || 0,
|
||||
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
|
||||
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
|
||||
@@ -36,6 +36,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
format: 'h:mm A',
|
||||
...currentConfig.clock,
|
||||
},
|
||||
backgroundUrls: currentConfig.backgroundUrls || [],
|
||||
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
@@ -69,7 +71,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
}, 300); // This duration should match the transition duration
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
|
||||
const { name, value } = e.target;
|
||||
if (name.startsWith('serverWidget.')) {
|
||||
const field = name.split('.')[1];
|
||||
@@ -157,7 +159,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updatedUserWallpapers);
|
||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||
setConfig({ ...config, backgroundUrl: newWallpaperUrl });
|
||||
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
|
||||
|
||||
setNewWallpaperName('');
|
||||
setNewWallpaperUrl('');
|
||||
@@ -186,23 +188,20 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
};
|
||||
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
|
||||
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
|
||||
setConfig({ ...config, backgroundUrl: base64 });
|
||||
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, base64] });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
|
||||
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
|
||||
const wallpaperIdentifier = wallpaper.url || wallpaper.base64;
|
||||
const updatedUserWallpapers = userWallpapers.filter(w => (w.url || w.base64) !== wallpaperIdentifier);
|
||||
setUserWallpapers(updatedUserWallpapers);
|
||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||
|
||||
if (config.backgroundUrl === (wallpaper.url || wallpaper.base64)) {
|
||||
const nextWallpaper = baseWallpapers[0] || updatedUserWallpapers[0];
|
||||
if (nextWallpaper) {
|
||||
setConfig({ ...config, backgroundUrl: nextWallpaper.url || nextWallpaper.base64 });
|
||||
}
|
||||
}
|
||||
const newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
|
||||
setConfig({ ...config, backgroundUrls: newBackgroundUrls });
|
||||
};
|
||||
|
||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||
@@ -328,6 +327,19 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
||||
<Dropdown
|
||||
name="horizontalAlignment"
|
||||
value={config.horizontalAlignment}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -336,23 +348,27 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
||||
<Dropdown
|
||||
name="backgroundUrl"
|
||||
value={config.backgroundUrl}
|
||||
name="backgroundUrls"
|
||||
value={config.backgroundUrls}
|
||||
onChange={handleChange}
|
||||
multiple
|
||||
options={allWallpapers.map(w => ({
|
||||
value: w.url || w.base64 || '',
|
||||
label: (
|
||||
<div className="flex items-center justify-between">
|
||||
{w.name}
|
||||
{!baseWallpapers.includes(w) && (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>{w.name}</span>
|
||||
{!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteWallpaper(w);
|
||||
}}
|
||||
className="text-red-500 hover:text-red-400 ml-4"
|
||||
className="text-red-500 hover:text-red-400 ml-4 p-1 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<Trash size={16} />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -360,6 +376,24 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{Array.isArray(config.backgroundUrls) && config.backgroundUrls.length > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
||||
<Dropdown
|
||||
name="wallpaperFrequency"
|
||||
value={config.wallpaperFrequency}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '3h', label: '3 hours' },
|
||||
{ value: '6h', label: '6 hours' },
|
||||
{ value: '12h', label: '12 hours' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '2d', label: '2 days' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||
<div className="flex items-center gap-4">
|
||||
|
@@ -2,17 +2,16 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (e: { target: { name: string; value: string } }) => void;
|
||||
value: string | string[];
|
||||
onChange: (e: { target: { name: string; value: string | string[] } }) => void;
|
||||
name?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...rest }) => {
|
||||
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, multiple = false, ...rest }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOptionLabel = options.find(option => option.value === value)?.label || '';
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
@@ -26,14 +25,46 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
}, []);
|
||||
|
||||
const handleOptionClick = (optionValue: string) => {
|
||||
let newValue: string | string[];
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
if (currentValues.includes(optionValue)) {
|
||||
newValue = currentValues.filter((v) => v !== optionValue);
|
||||
} else {
|
||||
newValue = [...currentValues, optionValue];
|
||||
}
|
||||
} else {
|
||||
newValue = optionValue;
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name: name || '',
|
||||
value: optionValue,
|
||||
value: newValue,
|
||||
},
|
||||
};
|
||||
onChange(syntheticEvent);
|
||||
setIsOpen(false);
|
||||
onChange(syntheticEvent as any);
|
||||
};
|
||||
|
||||
const selectedOptionLabel = (() => {
|
||||
if (multiple) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
return options.find((o) => o.value === value[0])?.label || '';
|
||||
}
|
||||
return `${value.length} selected`;
|
||||
}
|
||||
return 'Select...';
|
||||
}
|
||||
return options.find((option) => option.value === value)?.label || 'Select...';
|
||||
})();
|
||||
|
||||
const isSelected = (optionValue: string) => {
|
||||
if (multiple && Array.isArray(value)) {
|
||||
return value.includes(optionValue);
|
||||
}
|
||||
return optionValue === value;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -72,12 +103,13 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
key={option.value}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
|
||||
${option.value === value
|
||||
${
|
||||
isSelected(option.value)
|
||||
? 'bg-cyan-500/20 text-cyan-300'
|
||||
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
aria-selected={isSelected(option.value)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</li>
|
||||
@@ -86,7 +118,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
)}
|
||||
|
||||
{/* Hidden input to mimic native select behavior for forms */}
|
||||
{name && <input type="hidden" name={name} value={value} />}
|
||||
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -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 { Website } from '../types';
|
||||
import IconPicker from './IconPicker';
|
||||
import { getWebsiteIcon } from './utils/iconService';
|
||||
import { icons } from 'lucide-react';
|
||||
|
||||
interface WebsiteEditModalProps {
|
||||
website?: Website;
|
||||
@@ -12,21 +10,70 @@ interface WebsiteEditModalProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
interface IconMetadata {
|
||||
name: string;
|
||||
base: string;
|
||||
aliases: string[];
|
||||
categories: string[];
|
||||
update: {
|
||||
timestamp: string;
|
||||
author: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
colors: any; // this can be anything I guess
|
||||
}
|
||||
|
||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||
const [name, setName] = useState(website ? website.name : '');
|
||||
const [url, setUrl] = useState(website ? website.url : '');
|
||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [iconQuery, setIconQuery] = useState('');
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/icon-metadata.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const iconsArray = Object.entries(data).map(([name, details]) => ({
|
||||
name,
|
||||
...details
|
||||
}));
|
||||
// Expand colors into separate entries
|
||||
iconsArray.forEach(icon => {
|
||||
if (icon.colors) {
|
||||
const colors = Object.values(icon.colors).filter(key => key !== icon.name);
|
||||
for (const color of colors) {
|
||||
const newIcon = { ...icon };
|
||||
newIcon.name = color;
|
||||
iconsArray.push(newIcon);
|
||||
}
|
||||
}
|
||||
});
|
||||
setIconMetadata(iconsArray);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (iconQuery && Array.isArray(iconMetadata)) {
|
||||
const lowerCaseQuery = iconQuery.toLowerCase();
|
||||
const filtered = iconMetadata
|
||||
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery))
|
||||
.slice(0, 50);
|
||||
setFilteredIcons(filtered);
|
||||
} else {
|
||||
setFilteredIcons([]);
|
||||
}
|
||||
}, [iconQuery, iconMetadata]);
|
||||
|
||||
const fetchIcon = async () => {
|
||||
if (url) {
|
||||
const fetchedIcon = await getWebsiteIcon(url);
|
||||
setIcon(fetchedIcon);
|
||||
}
|
||||
};
|
||||
fetchIcon();
|
||||
}, [url]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ id: website?.id, name, url, icon });
|
||||
@@ -38,18 +85,22 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
}
|
||||
};
|
||||
|
||||
const LucideIcon = icons[icon as keyof typeof icons];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
||||
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
|
||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
{LucideIcon ? (
|
||||
<LucideIcon className="h-24 w-24 text-white" />
|
||||
) : (
|
||||
{icon ? (
|
||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||
) : (
|
||||
<div className="h-24 w-24 bg-white/10 rounded-lg flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
@@ -67,25 +118,44 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Icon URL or name"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setIcon(e.target.value);
|
||||
setIconQuery(e.target.value);
|
||||
}}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
||||
/>
|
||||
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
||||
{showIconPicker ? 'Close' : 'Select Icon'}
|
||||
{filteredIcons.length > 0 && (
|
||||
<div className="absolute z-10 w-full bg-gray-800 rounded-lg mt-1 max-h-60 overflow-y-auto">
|
||||
{filteredIcons.map(iconData => (
|
||||
<div
|
||||
key={iconData.name}
|
||||
onClick={() => {
|
||||
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||
setIcon(iconUrl);
|
||||
setFilteredIcons([]);
|
||||
}}
|
||||
className="cursor-pointer flex items-center p-2 hover:bg-gray-700"
|
||||
>
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
||||
alt={iconData.name}
|
||||
className="h-6 w-6 mr-2"
|
||||
/>
|
||||
<span>{iconData.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={fetchIcon} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
{showIconPicker && (
|
||||
<IconPicker
|
||||
onSelect={(iconName) => {
|
||||
setIcon(iconName);
|
||||
setShowIconPicker(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
|
@@ -1,42 +1,99 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Website } from '../types';
|
||||
import { icons, ArrowLeft, ArrowRight, Pencil } from 'lucide-react';
|
||||
|
||||
|
||||
interface WebsiteTileProps {
|
||||
website: Website;
|
||||
isEditing: boolean;
|
||||
onEdit: (website: Website) => void;
|
||||
onMove: (website: Website, direction: 'left' | 'right') => void;
|
||||
className?: string;
|
||||
tileSize?: string;
|
||||
}
|
||||
|
||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, className }) => {
|
||||
const LucideIcon = icons[website.icon as keyof typeof icons];
|
||||
const getTileSizeClass = (size: string | undefined) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-28 h-28';
|
||||
case 'medium':
|
||||
return 'w-32 h-32';
|
||||
case 'large':
|
||||
return 'w-36 h-36';
|
||||
default:
|
||||
return 'w-32 h-32';
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = (size: string | undefined) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 8;
|
||||
case 'medium':
|
||||
return 10;
|
||||
case 'large':
|
||||
return 12;
|
||||
default:
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isEditing) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate loading time (dev purpose)
|
||||
// e.preventDefault();
|
||||
// setTimeout(() => {
|
||||
// setIsLoading(false);
|
||||
// }, 3500); // Small delay to show spinner before navigation
|
||||
};
|
||||
|
||||
const iconSizeClass = `w-${getIconSize(tileSize)} h-${getIconSize(tileSize)}`;
|
||||
const iconSizeLoadingClass = `w-${getIconSize(tileSize) - 4} h-${getIconSize(tileSize) - 4}`;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className} transition-all duration-300 ease-in-out`}>
|
||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-300 ease-in-out`}>
|
||||
<a
|
||||
href={isEditing ? undefined : website.url}
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-300 ease-in-out hover:scale-105 hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
|
||||
>
|
||||
<div className="mb-2 transition-transform duration-300 group-hover:-translate-y-1">
|
||||
{LucideIcon ? (
|
||||
<LucideIcon className="h-10 w-10 text-white" />
|
||||
) : (
|
||||
<img src={website.icon} alt={`${website.name} icon`} className="h-10 w-10 object-contain" />
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center mb-6">
|
||||
<svg className="animate-spin h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-slate-100 font-medium text-base tracking-wide text-center">
|
||||
)}
|
||||
<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}`}>
|
||||
<img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
|
||||
</div>
|
||||
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
|
||||
{website.name}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{isEditing && (
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
|
||||
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><ArrowLeft size={16} /></button>
|
||||
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><Pencil size={16} /></button>
|
||||
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><ArrowRight size={16} /></button>
|
||||
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-left" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/>
|
||||
</svg></button>
|
||||
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg></button>
|
||||
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-right" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"/>
|
||||
</svg></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
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;
|
@@ -20,6 +20,6 @@ export const baseWallpapers: Wallpaper[] = [
|
||||
},
|
||||
{
|
||||
name: 'Waves',
|
||||
url: 'waves.jpg',
|
||||
url: '/waves.jpg',
|
||||
},
|
||||
];
|
||||
|
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 |
15
index.html
15
index.html
@@ -4,19 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vision Start</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@^19.1.0",
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.1.0/",
|
||||
"react/": "https://esm.sh/react@^19.1.0/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-black">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
|
15
manifest.json
Normal file
15
manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Vision Startpage",
|
||||
"version": "1.0",
|
||||
"description": "A beautiful and customizable startpage for your browser.",
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "index.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||
}
|
||||
}
|
1428
package-lock.json
generated
1428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
|
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}, // ← use the new package name
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Executable file
BIN
public/favicon.ico
Executable file
Binary file not shown.
After Width: | Height: | Size: 763 KiB |
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
27
types.ts
27
types.ts
@@ -1,4 +1,3 @@
|
||||
|
||||
export interface Website {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,3 +23,29 @@ export interface Wallpaper {
|
||||
url?: 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;
|
||||
}
|
@@ -1,14 +1,27 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
define: { },
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'index.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'assets/[name].js',
|
||||
chunkFileNames: 'assets/[name].js',
|
||||
assetFileNames: 'assets/[name].[ext]'
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
// Ensure CSS is extracted to a separate file
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
// Base path for Chrome extension
|
||||
base: './',
|
||||
|
||||
})
|
Reference in New Issue
Block a user