Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85b239f540 | |||
| 7efdd17534 | |||
| b1957f2c19 |
39
App.tsx
39
App.tsx
@@ -10,29 +10,7 @@ import EditButton from './components/layout/EditButton';
|
|||||||
import ConfigurationButton from './components/layout/ConfigurationButton';
|
import ConfigurationButton from './components/layout/ConfigurationButton';
|
||||||
import CategoryGroup from './components/layout/CategoryGroup';
|
import CategoryGroup from './components/layout/CategoryGroup';
|
||||||
import Wallpaper from './components/Wallpaper';
|
import Wallpaper from './components/Wallpaper';
|
||||||
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
const defaultConfig: Config = {
|
|
||||||
title: 'Vision Start',
|
|
||||||
currentWallpapers: ['Abstract'],
|
|
||||||
wallpaperFrequency: '1d',
|
|
||||||
wallpaperBlur: 0,
|
|
||||||
wallpaperBrightness: 100,
|
|
||||||
wallpaperOpacity: 100,
|
|
||||||
titleSize: 'medium',
|
|
||||||
alignment: 'middle',
|
|
||||||
horizontalAlignment: 'middle',
|
|
||||||
clock: {
|
|
||||||
enabled: true,
|
|
||||||
size: 'medium',
|
|
||||||
font: 'Helvetica',
|
|
||||||
format: 'h:mm A',
|
|
||||||
},
|
|
||||||
serverWidget: {
|
|
||||||
enabled: false,
|
|
||||||
pingFrequency: 15,
|
|
||||||
servers: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<Category[]>(() => {
|
const [categories, setCategories] = useState<Category[]>(() => {
|
||||||
@@ -52,21 +30,10 @@ const App: React.FC = () => {
|
|||||||
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||||
const [config, setConfig] = useState<Config>(() => {
|
const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig());
|
||||||
try {
|
|
||||||
const storedConfig = localStorage.getItem('config');
|
|
||||||
if (storedConfig) {
|
|
||||||
const parsedConfig = JSON.parse(storedConfig);
|
|
||||||
return { ...defaultConfig, ...parsedConfig };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing config from localStorage', error);
|
|
||||||
}
|
|
||||||
return { ...defaultConfig };
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('config', JSON.stringify(config));
|
ConfigurationService.saveConfig(config);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -1,5 +1,12 @@
|
|||||||
# Vision Start
|
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
|
||||||
#### A glassmorphism-looking like, modern and customizable startpage built with React.
|
Vision Start
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
||||||
|
A glassmorphism-looking like, modern and customizable startpage built with React.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -25,7 +32,7 @@ Vision Start is not yet available on Chrome Web Store, but it can be installed m
|
|||||||
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
||||||
* **Glassmorphism UI:** A modern and stylish interface with a frosted glass effect.
|
* **Glassmorphism UI:** A modern and stylish interface with a frosted glass effect.
|
||||||
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
||||||
* **Future**: a long to do list :(
|
* **Settings:** A settings page to configure the startpage, with export/import functionality.
|
||||||
|
|
||||||
## Backgrounds
|
## Backgrounds
|
||||||
|
|
||||||
@@ -87,4 +94,3 @@ npm run dev
|
|||||||
|
|
||||||
From a technical side:
|
From a technical side:
|
||||||
* Refactor everything :(
|
* Refactor everything :(
|
||||||
* Add small nginx demo (with docker)
|
|
||||||
|
|||||||
@@ -1,71 +1,42 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import ToggleSwitch from './ToggleSwitch';
|
import { Config, Wallpaper } from '../types';
|
||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
|
||||||
import { Server, Wallpaper } from '../types';
|
|
||||||
|
|
||||||
import Dropdown from './Dropdown';
|
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||||
|
import { ConfigurationService } from './services/ConfigurationService';
|
||||||
|
import GeneralTab from './configuration/GeneralTab';
|
||||||
|
import ThemeTab from './configuration/ThemeTab';
|
||||||
|
import ClockTab from './configuration/ClockTab';
|
||||||
|
import ServerWidgetTab from './configuration/ServerWidgetTab';
|
||||||
|
|
||||||
interface ConfigurationModalProps {
|
interface ConfigurationModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (config: any) => void;
|
onSave: (config: Config) => void;
|
||||||
currentConfig: any;
|
currentConfig: Config;
|
||||||
onWallpaperChange: (newConfig: Partial<any>) => void;
|
onWallpaperChange: (newConfig: Partial<Config>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig, onWallpaperChange }) => {
|
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||||
const [config, setConfig] = useState({
|
onClose,
|
||||||
...currentConfig,
|
onSave,
|
||||||
titleSize: currentConfig.titleSize || 'medium',
|
currentConfig,
|
||||||
alignment: currentConfig.alignment || 'middle',
|
onWallpaperChange,
|
||||||
tileSize: currentConfig.tileSize || 'medium',
|
}) => {
|
||||||
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
|
const [config, setConfig] = useState<Config>(currentConfig);
|
||||||
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,
|
|
||||||
},
|
|
||||||
currentWallpapers: Array.isArray(currentConfig.currentWallpapers)
|
|
||||||
? currentConfig.currentWallpapers.filter((name: string) => typeof name === 'string')
|
|
||||||
: [],
|
|
||||||
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
|
|
||||||
});
|
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
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 [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const isSaving = useRef(false);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isSaving = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
||||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
setUserWallpapers(ConfigurationService.loadUserWallpapers());
|
||||||
if (storedUserWallpapers) {
|
|
||||||
setUserWallpapers(JSON.parse(storedUserWallpapers));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||||
setIsVisible(true);
|
|
||||||
}, 10);
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -77,167 +48,86 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
if (name === 'currentWallpapers') {
|
|
||||||
const wallpaperNames = Array.isArray(value) ? value : [value];
|
|
||||||
setConfig({ ...config, currentWallpapers: wallpaperNames });
|
|
||||||
} else 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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
||||||
// Set wallpaperState in localStorage with lastWallpaperChange datetime
|
ConfigurationService.resetWallpaperState();
|
||||||
localStorage.setItem('wallpaperState', JSON.stringify({
|
|
||||||
lastWallpaperChange: new Date().toISOString(),
|
|
||||||
currentIndex: 0,
|
|
||||||
}));
|
|
||||||
}, [config.currentWallpapers]);
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
const handleClockToggleChange = (checked: boolean) => {
|
const handleClose = () => {
|
||||||
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
|
setIsVisible(false);
|
||||||
|
setTimeout(onClose, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleServerWidgetToggleChange = (checked: boolean) => {
|
const handleConfigChange = (updates: Partial<Config>) => {
|
||||||
setConfig({
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
...config,
|
|
||||||
serverWidget: { ...config.serverWidget, enabled: checked },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddServer = () => {
|
const handleAddWallpaper = async (name: string, url: string) => {
|
||||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
||||||
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
const newServer: Server = {
|
setUserWallpapers(updated);
|
||||||
id: Date.now().toString(),
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
name: newServerName,
|
setConfig((prev) => ({
|
||||||
address: newServerAddress,
|
...prev,
|
||||||
|
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
setConfig({
|
const handleAddWallpaperFile = async (file: File) => {
|
||||||
...config,
|
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
||||||
serverWidget: {
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
...config.serverWidget,
|
setUserWallpapers(updated);
|
||||||
servers: [...config.serverWidget.servers, newServer],
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
},
|
setConfig((prev) => ({
|
||||||
});
|
...prev,
|
||||||
|
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||||
setNewServerName('');
|
}));
|
||||||
setNewServerAddress('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveServer = (id: string) => {
|
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||||
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 = async () => {
|
|
||||||
if (newWallpaperUrl.trim() === '') return;
|
|
||||||
try {
|
try {
|
||||||
const finalName = await addWallpaperToChromeStorageLocal(newWallpaperName, newWallpaperUrl);
|
await ConfigurationService.deleteWallpaper(wallpaper);
|
||||||
const newWallpaper: Wallpaper = { name: finalName };
|
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
|
||||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
setUserWallpapers(updated);
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
|
||||||
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
|
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
|
||||||
setNewWallpaperName('');
|
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
|
||||||
setNewWallpaperUrl('');
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error adding wallpaper. Please check the URL and try again.');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = async () => {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
|
||||||
const newWallpaper: Wallpaper = { name: finalName };
|
|
||||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
|
||||||
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error adding wallpaper. Please try again.');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUserWallpaper = async (wallpaper: Wallpaper) => {
|
|
||||||
try {
|
|
||||||
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
|
||||||
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
|
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
|
||||||
const newcurrentWallpapers = config.currentWallpapers.filter((name: string) => name !== wallpaper.name);
|
|
||||||
const newConfig = { ...config, currentWallpapers: newcurrentWallpapers };
|
|
||||||
setConfig(newConfig);
|
|
||||||
onWallpaperChange({ currentWallpapers: newcurrentWallpapers });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Error deleting wallpaper. Please try again.');
|
alert('Error deleting wallpaper. Please try again.');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const { config: importedConfig, userWallpapers: importedWallpapers } =
|
||||||
|
await ConfigurationService.importConfig(file);
|
||||||
|
setConfig(importedConfig);
|
||||||
|
setUserWallpapers(importedWallpapers);
|
||||||
|
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
|
||||||
|
onSave(importedConfig);
|
||||||
|
alert('Configuration imported successfully. The page will reload to apply all data.');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Could not import configuration. Please use a valid export JSON file.');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'theme', label: 'Theme' },
|
||||||
|
{ id: 'clock', label: 'Clock' },
|
||||||
|
{ id: 'serverWidget', label: 'Server Widget' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||||
<div
|
<div
|
||||||
@@ -245,7 +135,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
></div>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
@@ -257,401 +147,88 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
||||||
|
|
||||||
<div className="flex border-b border-white/10 mb-6">
|
<div className="flex border-b border-white/10 mb-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'general' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab('general')}
|
className={`px-4 py-2 text-lg font-semibold ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-cyan-400 border-b-2 border-cyan-400'
|
||||||
|
: 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
>
|
>
|
||||||
General
|
{tab.label}
|
||||||
</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>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<div className="flex flex-col gap-6">
|
<GeneralTab config={config} onChange={handleConfigChange} />
|
||||||
<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 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 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'theme' && (
|
{activeTab === 'theme' && (
|
||||||
<div className="flex flex-col gap-6">
|
<ThemeTab
|
||||||
<div className="flex items-center justify-between">
|
config={config}
|
||||||
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
onChange={handleConfigChange}
|
||||||
<Dropdown
|
userWallpapers={userWallpapers}
|
||||||
name="currentWallpapers"
|
allWallpapers={allWallpapers}
|
||||||
value={config.currentWallpapers}
|
chromeStorageAvailable={chromeStorageAvailable}
|
||||||
onChange={handleChange}
|
onAddWallpaper={handleAddWallpaper}
|
||||||
multiple
|
onAddWallpaperFile={handleAddWallpaperFile}
|
||||||
options={allWallpapers.map(w => ({
|
onDeleteWallpaper={handleDeleteWallpaper}
|
||||||
value: w.name,
|
|
||||||
label: w.name
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.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">
|
|
||||||
<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>
|
|
||||||
{chromeStorageAvailable && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{userWallpapers.map((wallpaper) => (
|
|
||||||
<div key={wallpaper.name} className="flex items-center justify-between bg-white/10 p-2 rounded-lg">
|
|
||||||
<span className="truncate">{wallpaper.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUserWallpaper(wallpaper)}
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</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 (optional for URLs)"
|
|
||||||
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' && (
|
{activeTab === 'clock' && (
|
||||||
<div className="flex flex-col gap-6">
|
<ClockTab config={config} onChange={handleConfigChange} />
|
||||||
<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' && (
|
{activeTab === 'serverWidget' && (
|
||||||
<div className="flex flex-col gap-6">
|
<ServerWidgetTab config={config} onChange={handleConfigChange} />
|
||||||
<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>
|
||||||
|
|
||||||
<div className="p-8 border-t border-white/10">
|
<div className="p-8 border-t border-white/10">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => ConfigurationService.exportConfig()}
|
||||||
|
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
isSaving.current = true;
|
||||||
|
onSave(config);
|
||||||
|
}}
|
||||||
|
className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
|
||||||
|
>
|
||||||
Save & Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
69
components/configuration/ClockTab.tsx
Normal file
69
components/configuration/ClockTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Dropdown from '../Dropdown';
|
||||||
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
import { Config } from '../../types';
|
||||||
|
|
||||||
|
interface ClockTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
||||||
|
const updateClock = (updates: Partial<Config['clock']>) => {
|
||||||
|
onChange({ clock: { ...config.clock, ...updates } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={(checked) => updateClock({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</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={(e) => updateClock({ size: e.target.value as string })}
|
||||||
|
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={(e) => updateClock({ font: e.target.value as string })}
|
||||||
|
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={(e) => updateClock({ format: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'h:mm A', label: 'AM/PM' },
|
||||||
|
{ value: 'HH:mm', label: '24:00' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClockTab;
|
||||||
79
components/configuration/GeneralTab.tsx
Normal file
79
components/configuration/GeneralTab.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Dropdown from '../Dropdown';
|
||||||
|
import { Config } from '../../types';
|
||||||
|
|
||||||
|
interface GeneralTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
value={config.title}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
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={(e) => onChange({ titleSize: e.target.value as string })}
|
||||||
|
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={(e) => onChange({ alignment: e.target.value as string })}
|
||||||
|
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 || 'medium'}
|
||||||
|
onChange={(e) => onChange({ tileSize: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ 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">Horizontal Alignment</label>
|
||||||
|
<Dropdown
|
||||||
|
name="horizontalAlignment"
|
||||||
|
value={config.horizontalAlignment}
|
||||||
|
onChange={(e) => onChange({ horizontalAlignment: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'left', label: 'Left' },
|
||||||
|
{ value: 'middle', label: 'Middle' },
|
||||||
|
{ value: 'right', label: 'Right' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeneralTab;
|
||||||
150
components/configuration/ServerWidgetTab.tsx
Normal file
150
components/configuration/ServerWidgetTab.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
import { Config, Server } from '../../types';
|
||||||
|
|
||||||
|
interface ServerWidgetTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
|
const [newServerName, setNewServerName] = useState('');
|
||||||
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
|
|
||||||
|
const updateServerWidget = (updates: Partial<Config['serverWidget']>) => {
|
||||||
|
onChange({ serverWidget: { ...config.serverWidget, ...updates } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddServer = () => {
|
||||||
|
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||||
|
const newServer: Server = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: newServerName,
|
||||||
|
address: newServerAddress,
|
||||||
|
};
|
||||||
|
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
|
||||||
|
setNewServerName('');
|
||||||
|
setNewServerAddress('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveServer = (id: string) => {
|
||||||
|
updateServerWidget({
|
||||||
|
servers: config.serverWidget.servers.filter((s) => s.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);
|
||||||
|
updateServerWidget({ servers: items });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={(checked) => updateServerWidget({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
min="5"
|
||||||
|
max="60"
|
||||||
|
value={config.serverWidget.pingFrequency}
|
||||||
|
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerWidgetTab;
|
||||||
228
components/configuration/ThemeTab.tsx
Normal file
228
components/configuration/ThemeTab.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Dropdown from '../Dropdown';
|
||||||
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
|
||||||
|
interface ThemeTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
userWallpapers: Wallpaper[];
|
||||||
|
allWallpapers: Wallpaper[];
|
||||||
|
chromeStorageAvailable: boolean;
|
||||||
|
onAddWallpaper: (name: string, url: string) => Promise<void>;
|
||||||
|
onAddWallpaperFile: (file: File) => Promise<void>;
|
||||||
|
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
userWallpapers,
|
||||||
|
allWallpapers,
|
||||||
|
chromeStorageAvailable,
|
||||||
|
onAddWallpaper,
|
||||||
|
onAddWallpaperFile,
|
||||||
|
onDeleteWallpaper,
|
||||||
|
}) => {
|
||||||
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleAddWallpaper = async () => {
|
||||||
|
if (newWallpaperUrl.trim() === '') return;
|
||||||
|
try {
|
||||||
|
await onAddWallpaper(newWallpaperName, newWallpaperUrl);
|
||||||
|
setNewWallpaperName('');
|
||||||
|
setNewWallpaperUrl('');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error adding wallpaper. Please check the URL and try again.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await onAddWallpaperFile(file);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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="currentWallpapers"
|
||||||
|
value={config.currentWallpapers}
|
||||||
|
onChange={(e) => onChange({ currentWallpapers: e.target.value as string[] })}
|
||||||
|
multiple
|
||||||
|
options={allWallpapers.map((w) => ({ value: w.name, label: w.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.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={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
|
||||||
|
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">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={config.wallpaperBlur}
|
||||||
|
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||||
|
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"
|
||||||
|
min="0"
|
||||||
|
max="200"
|
||||||
|
value={config.wallpaperBrightness}
|
||||||
|
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||||
|
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"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={config.wallpaperOpacity}
|
||||||
|
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<span>{config.wallpaperOpacity}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{chromeStorageAvailable && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{userWallpapers.map((wallpaper) => (
|
||||||
|
<div
|
||||||
|
key={wallpaper.name}
|
||||||
|
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
||||||
|
>
|
||||||
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</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 (optional for URLs)"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeTab;
|
||||||
181
components/services/ConfigurationService.ts
Normal file
181
components/services/ConfigurationService.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
import {
|
||||||
|
addWallpaperToChromeStorageLocal,
|
||||||
|
removeWallpaperFromChromeStorageLocal,
|
||||||
|
} from '../utils/StorageLocalManager';
|
||||||
|
|
||||||
|
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||||
|
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: Config = {
|
||||||
|
title: 'Vision Start',
|
||||||
|
currentWallpapers: ['Abstract'],
|
||||||
|
wallpaperFrequency: '1d',
|
||||||
|
wallpaperBlur: 0,
|
||||||
|
wallpaperBrightness: 100,
|
||||||
|
wallpaperOpacity: 100,
|
||||||
|
titleSize: 'medium',
|
||||||
|
alignment: 'middle',
|
||||||
|
horizontalAlignment: 'middle',
|
||||||
|
tileSize: 'medium',
|
||||||
|
clock: {
|
||||||
|
enabled: true,
|
||||||
|
size: 'medium',
|
||||||
|
font: 'Helvetica',
|
||||||
|
format: 'h:mm A',
|
||||||
|
},
|
||||||
|
serverWidget: {
|
||||||
|
enabled: false,
|
||||||
|
pingFrequency: 15,
|
||||||
|
servers: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeParse = (value: string | null): unknown => {
|
||||||
|
if (value === null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toStorageString = (value: unknown): string =>
|
||||||
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
export const ConfigurationService = {
|
||||||
|
loadConfig(): Config {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('config');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return { ...DEFAULT_CONFIG, ...parsed };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing config from localStorage', error);
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_CONFIG };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveConfig(config: Config): void {
|
||||||
|
localStorage.setItem('config', JSON.stringify(config));
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUserWallpapers(): Wallpaper[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('userWallpapers');
|
||||||
|
if (stored) return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
saveUserWallpapers(wallpapers: Wallpaper[]): void {
|
||||||
|
localStorage.setItem('userWallpapers', JSON.stringify(wallpapers));
|
||||||
|
},
|
||||||
|
|
||||||
|
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
||||||
|
const finalName = await addWallpaperToChromeStorageLocal(name, url);
|
||||||
|
return { name: finalName };
|
||||||
|
},
|
||||||
|
|
||||||
|
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (file.size > 4 * 1024 * 1024) {
|
||||||
|
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
if (base64.length > 4.5 * 1024 * 1024) {
|
||||||
|
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||||
|
resolve({ name: finalName });
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||||
|
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||||
|
},
|
||||||
|
|
||||||
|
exportConfig(): void {
|
||||||
|
const exportPayload = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
requiredLocalStorageKeys: [...REQUIRED_LOCAL_STORAGE_KEYS],
|
||||||
|
localStorage: REQUIRED_LOCAL_STORAGE_KEYS.reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = safeParse(localStorage.getItem(key));
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<RequiredLocalStorageKey, unknown>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `vision-start-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
const parsed = JSON.parse(fileContent);
|
||||||
|
const localStorageData =
|
||||||
|
parsed?.localStorage && typeof parsed.localStorage === 'object'
|
||||||
|
? parsed.localStorage
|
||||||
|
: parsed;
|
||||||
|
|
||||||
|
if (!localStorageData || typeof localStorageData !== 'object') {
|
||||||
|
throw new Error('Invalid import file format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedAny = false;
|
||||||
|
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
|
||||||
|
const rawValue = (localStorageData as Record<string, unknown>)[key];
|
||||||
|
localStorage.setItem(key, toStorageString(rawValue));
|
||||||
|
importedAny = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!importedAny) {
|
||||||
|
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
|
||||||
|
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||||
|
.userWallpapers as Wallpaper[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: importedConfig || { ...DEFAULT_CONFIG },
|
||||||
|
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
resetWallpaperState(): void {
|
||||||
|
localStorage.setItem(
|
||||||
|
'wallpaperState',
|
||||||
|
JSON.stringify({
|
||||||
|
lastWallpaperChange: new Date().toISOString(),
|
||||||
|
currentIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user