This commit is contained in:
José Henrique 2025-07-18 22:07:22 -03:00
commit d30fd8d30b
25 changed files with 3270 additions and 0 deletions

24
.gitignore vendored Executable file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

410
App.tsx Executable file
View File

@ -0,0 +1,410 @@
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 WebsiteEditModal from './components/WebsiteEditModal';
import CategoryEditModal from './components/CategoryEditModal';
import { PlusCircle, Pencil } from 'lucide-react';
import { baseWallpapers } from './components/utils/baseWallpapers';
const defaultConfig = {
title: 'Vision Start',
subtitle: 'Your personal portal to the web.',
backgroundUrl: '/waves.jpg',
wallpaperBlur: 0,
wallpaperBrightness: 100,
wallpaperOpacity: 100,
titleSize: 'medium',
subtitleSize: 'medium',
alignment: 'middle',
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
},
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
},
};
const App: React.FC = () => {
const [categories, setCategories] = useState<Category[]>(() => {
try {
const storedCategories = localStorage.getItem('categories');
if (storedCategories) {
return JSON.parse(storedCategories);
}
} catch (error) {
console.error('Error parsing categories from localStorage', error);
}
return DEFAULT_CATEGORIES;
});
const [isEditing, setIsEditing] = useState(false);
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [editingWebsite, setEditingWebsite] = useState<Website | null>(null);
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [config, setConfig] = useState(() => {
try {
const storedConfig = localStorage.getItem('config');
if (storedConfig) {
return { ...defaultConfig, ...JSON.parse(storedConfig) };
}
} catch (error) {
console.error('Error parsing config from localStorage', error);
}
return { ...defaultConfig };
});
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>(() => {
const storedUserWallpapers = localStorage.getItem('userWallpapers');
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
});
const allWallpapers = [...baseWallpapers, ...userWallpapers];
const selectedWallpaper = allWallpapers.find(w => w.url === config.backgroundUrl || w.base64 === config.backgroundUrl);
useEffect(() => {
localStorage.setItem('categories', JSON.stringify(categories));
localStorage.setItem('config', JSON.stringify(config));
}, [categories, config]);
const handleSaveConfig = (newConfig: any) => {
setConfig(newConfig);
setIsConfigModalOpen(false);
};
const handleSaveWebsite = (website: Partial<Website>) => {
if (editingWebsite) {
const newCategories = categories.map(category => ({
...category,
websites: category.websites.map(w =>
w.id === website.id ? { ...w, ...website } : w
),
}));
setCategories(newCategories);
setEditingWebsite(null);
} else if (addingWebsite) {
const newWebsite: Website = {
id: Date.now().toString(),
name: website.name || '',
url: website.url || '',
icon: website.icon || '',
categoryId: addingWebsite.id,
};
const newCategories = categories.map(category =>
category.id === addingWebsite.id
? { ...category, websites: [...category.websites, newWebsite] }
: category
);
setCategories(newCategories);
setAddingWebsite(null);
}
};
const handleSaveCategory = (name: string) => {
if (editingCategory) {
const newCategories = categories.map(category =>
category.id === editingCategory.id ? { ...category, name } : category
);
setCategories(newCategories);
} else {
const newCategory: Category = {
id: Date.now().toString(),
name,
websites: [],
};
setCategories([...categories, newCategory]);
}
setEditingCategory(null);
setIsCategoryModalOpen(false);
};
const handleDeleteWebsite = () => {
if (!editingWebsite) return;
const newCategories = categories.map(category => ({
...category,
websites: category.websites.filter(w => w.id !== editingWebsite.id),
}));
setCategories(newCategories);
setEditingWebsite(null);
};
const handleDeleteCategory = () => {
if (!editingCategory) return;
const newCategories = categories.filter(c => c.id !== editingCategory.id);
setCategories(newCategories);
setEditingCategory(null);
setIsCategoryModalOpen(false);
};
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
const categoryIndex = categories.findIndex(c => c.id === website.categoryId);
if (categoryIndex === -1) return;
const category = categories[categoryIndex];
const websiteIndex = category.websites.findIndex(w => w.id === website.id);
if (websiteIndex === -1) return;
const newCategories = [...categories];
const newWebsites = [...category.websites];
const [movedWebsite] = newWebsites.splice(websiteIndex, 1);
if (direction === 'left') {
const newCategoryIndex = (categoryIndex - 1 + categories.length) % categories.length;
newCategories[categoryIndex] = { ...category, websites: newWebsites };
const destCategory = newCategories[newCategoryIndex];
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
} else {
const newCategoryIndex = (categoryIndex + 1) % categories.length;
newCategories[categoryIndex] = { ...category, websites: newWebsites };
const destCategory = newCategories[newCategoryIndex];
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
}
setCategories(newCategories);
};
const getAlignmentClass = (alignment: string) => {
switch (alignment) {
case 'top':
return 'justify-start';
case 'middle':
return 'justify-center';
case 'bottom':
return 'justify-end';
default:
return 'justify-center';
}
};
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 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 (
<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 || ''}')`,
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>
{/* 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>
<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}
isEditing={isEditing}
onEdit={setEditingWebsite}
onMove={handleMoveWebsite}
className={getTileSizeClass(config.tileSize)}
/>
))}
{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">
<button
onClick={() => {
setEditingCategory(null);
setIsCategoryModalOpen(true);
}}
className="text-white/50 hover:text-white transition-colors"
>
<PlusCircle size={48} />
</button>
</div>
)}
</div>
{config.serverWidget.enabled && (
<div className="absolute bottom-4 right-4">
<ServerWidget config={config} />
</div>
)}
{(editingWebsite || addingWebsite) && (
<WebsiteEditModal
website={editingWebsite || undefined}
edit={!!editingWebsite}
onClose={() => {
setEditingWebsite(null);
setAddingWebsite(null);
}}
onSave={handleSaveWebsite}
onDelete={handleDeleteWebsite}
/>
)}
{isCategoryModalOpen && (
<CategoryEditModal
category={editingCategory || undefined}
edit={!!editingCategory}
onClose={() => {
setEditingCategory(null);
setIsCategoryModalOpen(false);
}}
onSave={handleSaveCategory}
onDelete={handleDeleteCategory}
/>
)}
{isConfigModalOpen && (
<ConfigurationModal
currentConfig={config}
onClose={() => setIsConfigModalOpen(false)}
onSave={handleSaveConfig}
/>
)}
</main>
);
}
export default App;

17
README.md Executable file
View File

@ -0,0 +1,17 @@
# Vision Start
#### Small startpage
## Predefined themes
1. Abstract
2. Aurora (Vista vibes)
3. Mountain
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Run the app:
`npm run dev`

View File

@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { Category } from '../types';
interface CategoryEditModalProps {
category?: Category;
edit: boolean;
onClose: () => void;
onSave: (name: string) => void;
onDelete: () => void;
}
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(category ? category.name : '');
const handleSave = () => {
onSave(name);
};
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
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 Category' : 'Add Category'}</h2>
<div className="flex flex-col gap-4">
<input
type="text"
placeholder="Category Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex justify-between items-center mt-8">
<div>
{edit && (
<button onClick={onDelete} className="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-lg">
Delete
</button>
)}
</div>
<div className="flex justify-end gap-4">
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save
</button>
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default CategoryEditModal;

53
components/Clock.tsx Normal file
View File

@ -0,0 +1,53 @@
import React, { useState, useEffect } from 'react';
interface ClockProps {
config: {
clock: {
enabled: boolean;
size: string;
font: string;
format: string;
};
};
getClockSizeClass: (size: string) => string;
}
const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
const [time, setTime] = useState(new Date());
useEffect(() => {
const timerId = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timerId);
}, []);
if (!config.clock.enabled) {
return null;
}
const formatTime = (date: Date) => {
const hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
if (config.clock.format === 'HH:mm') {
return `${hours.toString().padStart(2, '0')}:${minutes}`;
}
const ampm = hours >= 12 ? 'PM' : 'AM';
const formattedHours = (hours % 12 || 12).toString();
return `${formattedHours}:${minutes} ${ampm}`;
};
return (
<div
className={`text-white font-bold ${getClockSizeClass(config.clock.size)}`}
style={{
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
fontFamily: config.clock.font,
}}
>
{formatTime(time)}
</div>
);
};
export default Clock;

View File

@ -0,0 +1,611 @@
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';
interface ConfigurationModalProps {
onClose: () => void;
onSave: (config: any) => void;
currentConfig: any;
}
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig }) => {
const [config, setConfig] = useState({
...currentConfig,
titleSize: currentConfig.titleSize || 'medium',
subtitleSize: currentConfig.subtitleSize || 'medium',
alignment: currentConfig.alignment || 'middle',
tileSize: currentConfig.tileSize || 'medium',
wallpaperBlur: currentConfig.wallpaperBlur || 0,
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
...currentConfig.serverWidget,
},
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
...currentConfig.clock,
},
});
const [activeTab, setActiveTab] = useState('general');
const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const storedUserWallpapers = localStorage.getItem('userWallpapers');
if (storedUserWallpapers) {
setUserWallpapers(JSON.parse(storedUserWallpapers));
}
}, []);
useEffect(() => {
// A small timeout to allow the component to mount before starting the transition
const timer = setTimeout(() => {
setIsVisible(true);
}, 10);
return () => clearTimeout(timer);
}, []);
const handleClose = () => {
setIsVisible(false);
setTimeout(() => {
onClose();
}, 300); // This duration should match the transition duration
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
if (name.startsWith('serverWidget.')) {
const field = name.split('.')[1];
setConfig({
...config,
serverWidget: { ...config.serverWidget, [field]: value },
});
} else if (name.startsWith('clock.')) {
const field = name.split('.')[1];
setConfig({
...config,
clock: { ...config.clock, [field]: value },
});
} else {
setConfig({ ...config, [name]: value });
}
};
const handleClockToggleChange = (checked: boolean) => {
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
};
const handleServerWidgetToggleChange = (checked: boolean) => {
setConfig({
...config,
serverWidget: { ...config.serverWidget, enabled: checked },
});
};
const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
const newServer: Server = {
id: Date.now().toString(),
name: newServerName,
address: newServerAddress,
};
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: [...config.serverWidget.servers, newServer],
},
});
setNewServerName('');
setNewServerAddress('');
};
const handleRemoveServer = (id: string) => {
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: config.serverWidget.servers.filter((server: Server) => server.id !== id),
},
});
};
const onDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(config.serverWidget.servers);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: items,
},
});
};
const handleAddWallpaper = () => {
if (newWallpaperName.trim() === '' || newWallpaperUrl.trim() === '') return;
const newWallpaper: Wallpaper = {
name: newWallpaperName,
url: newWallpaperUrl,
};
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, backgroundUrl: newWallpaperUrl });
setNewWallpaperName('');
setNewWallpaperUrl('');
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 4 * 1024 * 1024) {
alert('File size exceeds 4MB. Please choose a smaller file.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
alert('The uploaded image is too large. Please choose a smaller file.');
return;
}
const updatedUserWallpapers = userWallpapers.filter(w => !w.base64);
const newWallpaper: Wallpaper = {
name: file.name,
base64,
};
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
setConfig({ ...config, backgroundUrl: base64 });
};
reader.readAsDataURL(file);
}
};
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
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 allWallpapers = [...baseWallpapers, ...userWallpapers];
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
<div
className={`fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-in-out ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
onClick={handleClose}
></div>
<div
ref={menuRef}
className={`fixed top-0 right-0 h-full w-full max-w-lg bg-black/50 backdrop-blur-xl border-l border-white/10 text-white flex flex-col transition-transform duration-300 ease-in-out transform ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="p-8 flex-grow overflow-y-auto">
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
<div className="flex border-b border-white/10 mb-6">
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'general' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('general')}
>
General
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'theme' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('theme')}
>
Theme
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'clock' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('clock')}
>
Clock
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'serverWidget' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('serverWidget')}
>
Server Widget
</button>
</div>
{activeTab === 'general' && (
<div className="flex flex-col gap-6">
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
<input
type="text"
name="title"
value={config.title}
onChange={handleChange}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
<Dropdown
name="titleSize"
value={config.titleSize}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Subtitle</label>
<input
type="text"
name="subtitle"
value={config.subtitle}
onChange={handleChange}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Subtitle Size</label>
<Dropdown
name="subtitleSize"
value={config.subtitleSize}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
<Dropdown
name="alignment"
value={config.alignment}
onChange={handleChange}
options={[
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
<Dropdown
name="tileSize"
value={config.tileSize}
onChange={handleChange}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
</div>
)}
{activeTab === 'theme' && (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown
name="backgroundUrl"
value={config.backgroundUrl}
onChange={handleChange}
options={allWallpapers.map(w => ({
value: w.url || w.base64 || '',
label: (
<div className="flex items-center justify-between">
{w.name}
{!baseWallpapers.includes(w) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteWallpaper(w);
}}
className="text-red-500 hover:text-red-400 ml-4"
>
<Trash size={16} />
</button>
)}
</div>
)
}))}
/>
</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">
<input
type="range"
name="wallpaperBlur"
min="0"
max="50"
value={config.wallpaperBlur}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperBrightness"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperOpacity"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperOpacity}%</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddWallpaper}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
</svg>
<p className="mb-2 text-sm text-gray-400"><span className="font-semibold">Click to upload</span> or drag and drop</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
</label>
</div>
</div>
</div>
</div>
)}
{activeTab === 'clock' && (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
<ToggleSwitch
checked={config.clock.enabled}
onChange={handleClockToggleChange}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
<Dropdown
name="clock.size"
value={config.clock.size}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
<Dropdown
name="clock.font"
value={config.clock.font}
onChange={handleChange}
options={[
{ value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
<Dropdown
name="clock.format"
value={config.clock.format}
onChange={handleChange}
options={[
{ value: 'h:mm A', label: 'AM/PM' },
{ value: 'HH:mm', label: '24:00' },
]}
/>
</div>
</div>
)}
{activeTab === 'serverWidget' && (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
<ToggleSwitch
checked={config.serverWidget.enabled}
onChange={handleServerWidgetToggleChange}
/>
</div>
{config.serverWidget.enabled && (
<>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
name="serverWidget.pingFrequency"
min="5"
max="60"
value={config.serverWidget.pingFrequency}
onChange={handleChange}
className="w-48"
/>
<span>{config.serverWidget.pingFrequency}s</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="servers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex flex-col gap-2">
{config.serverWidget.servers.map((server: Server, index: number) => (
<Draggable key={server.id} draggableId={server.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
>
<div>
<p className="font-semibold">{server.name}</p>
<p className="text-sm text-slate-400">{server.address}</p>
</div>
<button
onClick={() => handleRemoveServer(server.id)}
className="text-red-500 hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex gap-2 mt-2">
<input
type="text"
placeholder="Server Name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="HTTP Address"
value={newServerAddress}
onChange={(e) => setNewServerAddress(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddServer}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
</div>
</>
)}
</div>
)}
</div>
<div className="p-8 border-t border-white/10">
<div className="flex justify-end gap-4">
<button onClick={() => onSave(config)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save & Close
</button>
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
Cancel
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfigurationModal;

94
components/Dropdown.tsx Normal file
View File

@ -0,0 +1,94 @@
import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps {
options: { value: string; label: string }[];
value: string;
onChange: (e: { target: { name: string; value: string } }) => void;
name?: string;
}
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...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)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleOptionClick = (optionValue: string) => {
const syntheticEvent = {
target: {
name: name || '',
value: optionValue,
},
};
onChange(syntheticEvent);
setIsOpen(false);
};
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
className="bg-black/5 backdrop-blur-md border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-cyan-400 w-40 h-10 flex justify-between items-center transition-all duration-200 hover:bg-white/5"
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox"
aria-expanded={isOpen}
{...rest}
>
<span className="truncate">{selectedOptionLabel}</span>
<svg
className={`w-5 h-5 transition-transform duration-300 ease-in-out ${isOpen ? 'rotate-180' : 'rotate-0'}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
{isOpen && (
<ul
className="absolute z-10 mt-1 w-full bg-black/70 backdrop-blur-xl border border-white/20 rounded-lg shadow-2xl overflow-hidden animate-in slide-in-from-top-2 fade-in duration-200"
role="listbox"
>
{options.map((option) => (
<li
key={option.value}
onClick={() => handleOptionClick(option.value)}
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
${option.value === value
? 'bg-cyan-500/20 text-cyan-300'
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
}`}
role="option"
aria-selected={option.value === value}
>
<span className="truncate">{option.label}</span>
</li>
))}
</ul>
)}
{/* Hidden input to mimic native select behavior for forms */}
{name && <input type="hidden" name={name} value={value} />}
</div>
);
};
export default Dropdown;

255
components/EditModal.tsx Normal file
View File

@ -0,0 +1,255 @@
import React, { useState, useRef, useEffect } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import { getWebsiteIcon } from './utils/iconService';
import { Category, Website } from '../types';
import IconPicker from './IconPicker';
import { icons } from 'lucide-react';
interface EditModalProps {
categories: Category[];
onClose: () => void;
onSave: (categories: Category[]) => void;
}
const EditModal: React.FC<EditModalProps> = ({ categories, onClose, onSave }) => {
const [localCategories, setLocalCategories] = useState(categories);
const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id || null);
const [newCategoryName, setNewCategoryName] = useState('');
const [newWebsite, setNewWebsite] = useState({ name: '', url: '', icon: '' });
const [showIconPicker, setShowIconPicker] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleOnDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId === destination.droppableId) {
const category = localCategories.find(cat => cat.id === source.droppableId);
if (category) {
const items = Array.from(category.websites);
const [reorderedItem] = items.splice(source.index, 1);
items.splice(destination.index, 0, reorderedItem);
const updatedCategories = localCategories.map(cat =>
cat.id === category.id ? { ...cat, websites: items } : cat
);
setLocalCategories(updatedCategories);
}
} else {
const sourceCategory = localCategories.find(cat => cat.id === source.droppableId);
const destCategory = localCategories.find(cat => cat.id === destination.droppableId);
if (sourceCategory && destCategory) {
const sourceItems = Array.from(sourceCategory.websites);
const [movedItem] = sourceItems.splice(source.index, 1);
const destItems = Array.from(destCategory.websites);
destItems.splice(destination.index, 0, { ...movedItem, categoryId: destCategory.id });
const updatedCategories = localCategories.map(cat => {
if (cat.id === sourceCategory.id) return { ...cat, websites: sourceItems };
if (cat.id === destCategory.id) return { ...cat, websites: destItems };
return cat;
});
setLocalCategories(updatedCategories);
}
}
};
const handleAddCategory = () => {
if (newCategoryName.trim() === '') return;
const newCategory: Category = {
id: Date.now().toString(),
name: newCategoryName,
websites: [],
};
setLocalCategories([...localCategories, newCategory]);
setNewCategoryName('');
};
const handleRemoveCategory = (id: string) => {
const updatedCategories = localCategories.filter(cat => cat.id !== id);
setLocalCategories(updatedCategories);
if (selectedCategoryId === id) {
setSelectedCategoryId(updatedCategories[0]?.id || null);
}
};
const handleAddWebsite = async () => {
if (!selectedCategoryId || !newWebsite.name || !newWebsite.url) return;
let icon = newWebsite.icon;
if (!icon || !Object.keys(icons).includes(icon)) {
icon = await getWebsiteIcon(newWebsite.url);
}
const newWebsiteData: Website = {
id: Date.now().toString(),
name: newWebsite.name,
url: newWebsite.url,
icon,
categoryId: selectedCategoryId,
};
const updatedCategories = localCategories.map(cat => {
if (cat.id === selectedCategoryId) {
return { ...cat, websites: [...cat.websites, newWebsiteData] };
}
return cat;
});
setLocalCategories(updatedCategories);
setNewWebsite({ name: '', url: '', icon: '' });
};
const handleRemoveWebsite = (categoryId: string, websiteId: string) => {
const updatedCategories = localCategories.map(cat => {
if (cat.id === categoryId) {
return { ...cat, websites: cat.websites.filter(web => web.id !== websiteId) };
}
return cat;
});
setLocalCategories(updatedCategories);
};
const selectedCategory = localCategories.find(cat => cat.id === selectedCategoryId);
return (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50">
<div ref={modalRef} className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-4xl text-white">
<h2 className="text-3xl font-bold mb-6">Edit Bookmarks</h2>
<div className="grid grid-cols-3 gap-8">
<div className="col-span-1">
<h3 className="text-xl font-semibold mb-4">Categories</h3>
<div className="flex flex-col gap-2 mb-4">
{localCategories.map(category => (
<div
key={category.id}
className={`flex justify-between items-center p-3 rounded-lg cursor-pointer ${selectedCategoryId === category.id ? 'bg-cyan-500/50' : 'bg-white/10'}`}
onClick={() => setSelectedCategoryId(category.id)}
>
<span>{category.name}</span>
<button onClick={() => handleRemoveCategory(category.id)} className="text-red-500 hover:text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="New Category"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button onClick={handleAddCategory} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg">
Add
</button>
</div>
</div>
<div className="col-span-2">
<h3 className="text-xl font-semibold mb-4">Websites</h3>
{selectedCategory && (
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId={selectedCategory.id}>
{(provided) => (
<ul {...provided.droppableProps} ref={provided.innerRef} className="mb-8">
{selectedCategory.websites.map((website, index) => (
<Draggable key={website.id} draggableId={website.id} index={index}>
{(provided) => (
<li
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between bg-white/10 p-3 rounded-lg mb-3"
>
<div className="flex items-center">
{Object.keys(icons).includes(website.icon) ? (
React.createElement(icons[website.icon as keyof typeof icons], { className: "h-8 w-8 mr-4" })
) : (
<img src={website.icon} alt={website.name} className="h-8 w-8 mr-4" />
)}
<span>{website.name}</span>
</div>
<button onClick={() => handleRemoveWebsite(selectedCategory.id, website.id)} className="text-red-500 hover:text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</li>
)}
</Draggable>
))}
{provided.placeholder}
</ul>
)}
</Droppable>
</DragDropContext>
)}
<div>
<h3 className="text-xl font-semibold mb-4">Add New Bookmark</h3>
<div className="flex flex-col gap-4">
<input
type="text"
placeholder="Name"
value={newWebsite.name}
onChange={(e) => setNewWebsite({ ...newWebsite, name: e.target.value })}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="URL"
value={newWebsite.url}
onChange={(e) => setNewWebsite({ ...newWebsite, url: e.target.value })}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Icon URL or name"
value={newWebsite.icon}
onChange={(e) => setNewWebsite({ ...newWebsite, icon: 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'}
</button>
</div>
{showIconPicker && (
<IconPicker
onSelect={(iconName) => {
setNewWebsite({ ...newWebsite, icon: iconName });
setShowIconPicker(false);
}}
/>
)}
<button onClick={handleAddWebsite} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-3 px-4 rounded-lg">
Add Bookmark
</button>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-4 mt-8">
<button onClick={() => onSave(localCategories)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save
</button>
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
Close
</button>
</div>
</div>
</div>
);
};
export default EditModal;

48
components/IconPicker.tsx Normal file
View File

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

View File

@ -0,0 +1,72 @@
import React, { useState, useEffect } from 'react';
import { Server } from '../types';
import ping from './utils/jsping.js';
interface ServerWidgetProps {
config: {
serverWidget: {
enabled: boolean;
pingFrequency: number;
servers: Server[];
};
};
}
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
useEffect(() => {
const pingServers = () => {
config.serverWidget.servers.forEach((server) => {
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'pending' }));
ping(server.address)
.then(() => {
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'online' }));
})
.catch(() => {
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'offline' }));
});
});
};
if (config.serverWidget.enabled) {
pingServers();
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
return () => clearInterval(interval);
}
}, [config.serverWidget.enabled, config.serverWidget.servers, config.serverWidget.pingFrequency]);
if (!config.serverWidget.enabled) {
return null;
}
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'bg-green-500';
case 'offline':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
return (
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-auto max-w-full">
<div className="flex items-center gap-4 bg-black/25 backdrop-blur-md border border-white/20 px-4 py-2 shadow-lg"
style={{ borderBottomLeftRadius: '0', borderBottomRightRadius: '0', borderTopLeftRadius: '16px', borderTopRightRadius: '15px', borderBottomWidth: '0' }}
>
{config.serverWidget.servers.map((server) => (
<div key={server.id} className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div>
<span className="text-slate-100 text-sm font-medium">
{server.name}
</span>
</div>
))}
</div>
</div>
);
};
export default ServerWidget;

View File

@ -0,0 +1,25 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange }) => {
const handleToggle = () => {
onChange(!checked);
};
return (
<div
className={`w-14 h-8 flex items-center rounded-full p-1 cursor-pointer transition-colors duration-300 ${checked ? 'bg-cyan-500' : 'bg-gray-600'}`}
onClick={handleToggle}
>
<div
className={`bg-white w-6 h-6 rounded-full shadow-md transform transition-transform duration-300 ${checked ? 'translate-x-6' : 'translate-x-0'}`}
/>
</div>
);
};
export default ToggleSwitch;

View File

@ -0,0 +1,112 @@
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;
edit: boolean;
onClose: () => void;
onSave: (website: Partial<Website>) => void;
onDelete: () => void;
}
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);
useEffect(() => {
const fetchIcon = async () => {
if (url) {
const fetchedIcon = await getWebsiteIcon(url);
setIcon(fetchedIcon);
}
};
fetchIcon();
}, [url]);
const handleSave = () => {
onSave({ id: website?.id, name, url, icon });
};
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
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" />
) : (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
)}
</div>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Icon URL or name"
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
/>
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
{showIconPicker ? 'Close' : 'Select Icon'}
</button>
</div>
{showIconPicker && (
<IconPicker
onSelect={(iconName) => {
setIcon(iconName);
setShowIconPicker(false);
}}
/>
)}
</div>
<div className="flex justify-between items-center mt-8">
<div>
{edit && (
<button onClick={onDelete} className="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-lg">
Delete
</button>
)}
</div>
<div className="flex justify-end gap-4">
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save
</button>
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default WebsiteEditModal;

46
components/WebsiteTile.tsx Executable file
View File

@ -0,0 +1,46 @@
import React 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;
}
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, className }) => {
const LucideIcon = icons[website.icon as keyof typeof icons];
return (
<div className={`relative ${className} transition-all duration-300 ease-in-out`}>
<a
href={isEditing ? undefined : website.url}
target="_self"
rel="noopener noreferrer"
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" />
)}
</div>
<span className="text-slate-100 font-medium text-base tracking-wide text-center">
{website.name}
</span>
</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>
</div>
)}
</div>
);
};
export default WebsiteTile;

View File

@ -0,0 +1,25 @@
import { Wallpaper } from '../../types';
export const baseWallpapers: Wallpaper[] = [
{
name: 'Abstract',
url: 'https://i.imgur.com/C6ynAtX.jpeg'
},
{
name: 'Abstract Red',
url: 'https://i.imgur.com/L89cqyP.jpeg'
},
{
name: 'Beach',
url: 'https://wallpapershome.com/images/pages/pic_h/615.jpg'
},
{
name: 'Mountain',
url: 'https://i.imgur.com/yHfOZUd.jpeg'
},
{
name: 'Waves',
url: 'waves.jpg',
},
];

View File

@ -0,0 +1,31 @@
async function getWebsiteIcon(url: string): Promise<string> {
try {
const response = await fetch(url);
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const appleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]');
if (appleTouchIcon) {
const href = appleTouchIcon.getAttribute('href');
if (href) {
return new URL(href, url).href;
}
}
const iconLink = doc.querySelector('link[rel="icon"][type="image/png"]') || doc.querySelector('link[rel="icon"]');
if (iconLink) {
const href = iconLink.getAttribute('href');
if (href) {
return new URL(href, url).href;
}
}
} catch (error) {
console.error('Error fetching and parsing HTML for icon:', error);
}
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
}
export { getWebsiteIcon };

View File

@ -0,0 +1,24 @@
function request_image(url) {
return new Promise(function(resolve, reject) {
var img = new Image();
img.onload = function() { resolve(img); };
img.onerror = function() { reject(url); };
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16);
});
}
function ping(url, multiplier) {
return new Promise(function(resolve, reject) {
var start = (new Date()).getTime();
var response = function() {
var delta = ((new Date()).getTime() - start);
delta *= (multiplier || 1);
resolve(delta);
};
request_image(url).then(response).catch(response);
setTimeout(function() { reject(Error('Timeout')); }, 5000);
});
}
export default ping;

18
constants.tsx Executable file
View File

@ -0,0 +1,18 @@
import { Category } from './types';
export const DEFAULT_CATEGORIES: Category[] = [
{
id: '1',
name: 'Search',
websites: [
{
id: '1',
name: 'Google',
url: 'https://www.google.com',
icon: 'https://www.google.com/s2/favicons?domain=google.com&sz=128',
categoryId: '1',
},
],
},
];

26
index.html Executable file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

16
index.tsx Executable file
View File

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

1210
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Executable file
View File

@ -0,0 +1,23 @@
{
"name": "vision-start",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@types/react": "^19.1.8",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}

30
tsconfig.json Executable file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"allowJs": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*" : ["./*"]
}
}
}

26
types.ts Executable file
View File

@ -0,0 +1,26 @@
export interface Website {
id: string;
name: string;
url: string;
icon: string;
categoryId: string;
}
export interface Server {
id: string;
name: string;
address: string;
}
export interface Category {
id: string;
name: string;
websites: Website[];
}
export interface Wallpaper {
name: string;
url?: string;
base64?: string;
}

14
vite.config.ts Executable file
View File

@ -0,0 +1,14 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
define: { },
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

BIN
waves.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB