big performance changes
This commit is contained in:
156
App.tsx
156
App.tsx
@@ -1,10 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||||
import ConfigurationModal from './components/ConfigurationModal';
|
|
||||||
import ServerWidget from './components/ServerWidget';
|
import ServerWidget from './components/ServerWidget';
|
||||||
import { DEFAULT_CATEGORIES } from './constants';
|
import { DEFAULT_CATEGORIES } from './constants';
|
||||||
import { Category, Website, Config } from './types';
|
import { Category, Website, Config } from './types';
|
||||||
import WebsiteEditModal from './components/WebsiteEditModal';
|
|
||||||
import CategoryEditModal from './components/CategoryEditModal';
|
|
||||||
import Header from './components/layout/Header';
|
import Header from './components/layout/Header';
|
||||||
import EditButton from './components/layout/EditButton';
|
import EditButton from './components/layout/EditButton';
|
||||||
import ConfigurationButton from './components/layout/ConfigurationButton';
|
import ConfigurationButton from './components/layout/ConfigurationButton';
|
||||||
@@ -12,6 +9,36 @@ import CategoryGroup from './components/layout/CategoryGroup';
|
|||||||
import Wallpaper from './components/Wallpaper';
|
import Wallpaper from './components/Wallpaper';
|
||||||
import { ConfigurationService } from './components/services/ConfigurationService';
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
|
|
||||||
|
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||||
|
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||||
|
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||||
|
|
||||||
|
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 getHorizontalAlignmentClass = (alignment: string) => {
|
||||||
|
switch (alignment) {
|
||||||
|
case 'left':
|
||||||
|
return 'justify-start';
|
||||||
|
case 'middle':
|
||||||
|
return 'justify-center';
|
||||||
|
case 'right':
|
||||||
|
return 'justify-end';
|
||||||
|
default:
|
||||||
|
return 'justify-center';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<Category[]>(() => {
|
const [categories, setCategories] = useState<Category[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -45,16 +72,16 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
const handleSaveConfig = (newConfig: Config) => {
|
const handleSaveConfig = useCallback((newConfig: Config) => {
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setIsConfigModalOpen(false);
|
setIsConfigModalOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleWallpaperChange = (newConfig: Partial<Config>) => {
|
const handleWallpaperChange = useCallback((newConfig: Partial<Config>) => {
|
||||||
setConfig(prev => ({ ...prev, ...newConfig }));
|
setConfig(prev => ({ ...prev, ...newConfig }));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleNextWallpaper = () => {
|
const handleNextWallpaper = useCallback(() => {
|
||||||
const names = config.currentWallpapers;
|
const names = config.currentWallpapers;
|
||||||
if (names.length === 0) return;
|
if (names.length === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -73,9 +100,9 @@ const App: React.FC = () => {
|
|||||||
console.error('Error advancing wallpaper state', error);
|
console.error('Error advancing wallpaper state', error);
|
||||||
}
|
}
|
||||||
setWallpaperVersion(v => v + 1);
|
setWallpaperVersion(v => v + 1);
|
||||||
};
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
const handleSaveWebsite = (website: Partial<Website>) => {
|
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
|
||||||
if (editingWebsite) {
|
if (editingWebsite) {
|
||||||
const idToUpdate = website.id ?? editingWebsite.id;
|
const idToUpdate = website.id ?? editingWebsite.id;
|
||||||
const newCategories = categories.map(category => ({
|
const newCategories = categories.map(category => ({
|
||||||
@@ -102,9 +129,9 @@ const App: React.FC = () => {
|
|||||||
setCategories(newCategories);
|
setCategories(newCategories);
|
||||||
setAddingWebsite(null);
|
setAddingWebsite(null);
|
||||||
}
|
}
|
||||||
};
|
}, [editingWebsite, addingWebsite, categories]);
|
||||||
|
|
||||||
const handleSaveCategory = (name: string) => {
|
const handleSaveCategory = useCallback((name: string) => {
|
||||||
if (editingCategory) {
|
if (editingCategory) {
|
||||||
const newCategories = categories.map(category =>
|
const newCategories = categories.map(category =>
|
||||||
category.id === editingCategory.id ? { ...category, name } : category
|
category.id === editingCategory.id ? { ...category, name } : category
|
||||||
@@ -120,9 +147,9 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
setIsCategoryModalOpen(false);
|
setIsCategoryModalOpen(false);
|
||||||
};
|
}, [editingCategory, categories]);
|
||||||
|
|
||||||
const handleDeleteWebsite = () => {
|
const handleDeleteWebsite = useCallback(() => {
|
||||||
if (!editingWebsite) return;
|
if (!editingWebsite) return;
|
||||||
|
|
||||||
const newCategories = categories.map(category => ({
|
const newCategories = categories.map(category => ({
|
||||||
@@ -131,18 +158,18 @@ const App: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
setCategories(newCategories);
|
setCategories(newCategories);
|
||||||
setEditingWebsite(null);
|
setEditingWebsite(null);
|
||||||
};
|
}, [editingWebsite, categories]);
|
||||||
|
|
||||||
const handleDeleteCategory = () => {
|
const handleDeleteCategory = useCallback(() => {
|
||||||
if (!editingCategory) return;
|
if (!editingCategory) return;
|
||||||
|
|
||||||
const newCategories = categories.filter(c => c.id !== editingCategory.id);
|
const newCategories = categories.filter(c => c.id !== editingCategory.id);
|
||||||
setCategories(newCategories);
|
setCategories(newCategories);
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
setIsCategoryModalOpen(false);
|
setIsCategoryModalOpen(false);
|
||||||
};
|
}, [editingCategory, categories]);
|
||||||
|
|
||||||
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
|
const handleMoveWebsite = useCallback((website: Website, direction: 'left' | 'right') => {
|
||||||
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
|
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
|
||||||
if (categoryIndex === -1) return;
|
if (categoryIndex === -1) return;
|
||||||
|
|
||||||
@@ -169,33 +196,7 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setCategories(newCategories);
|
setCategories(newCategories);
|
||||||
};
|
}, [categories]);
|
||||||
|
|
||||||
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 getHorizontalAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'left':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'right':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -226,7 +227,8 @@ const App: React.FC = () => {
|
|||||||
setEditingWebsite={setEditingWebsite}
|
setEditingWebsite={setEditingWebsite}
|
||||||
handleMoveWebsite={handleMoveWebsite}
|
handleMoveWebsite={handleMoveWebsite}
|
||||||
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
||||||
config={config}
|
horizontalAlignment={config.horizontalAlignment}
|
||||||
|
tileSize={config.tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
@@ -250,39 +252,45 @@ const App: React.FC = () => {
|
|||||||
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
||||||
|
|
||||||
{(editingWebsite || addingWebsite) && (
|
{(editingWebsite || addingWebsite) && (
|
||||||
<WebsiteEditModal
|
<Suspense fallback={null}>
|
||||||
website={editingWebsite || undefined}
|
<WebsiteEditModal
|
||||||
edit={!!editingWebsite}
|
website={editingWebsite || undefined}
|
||||||
onClose={() => {
|
edit={!!editingWebsite}
|
||||||
setEditingWebsite(null);
|
onClose={() => {
|
||||||
setAddingWebsite(null);
|
setEditingWebsite(null);
|
||||||
}}
|
setAddingWebsite(null);
|
||||||
onSave={handleSaveWebsite}
|
}}
|
||||||
onDelete={handleDeleteWebsite}
|
onSave={handleSaveWebsite}
|
||||||
/>
|
onDelete={handleDeleteWebsite}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCategoryModalOpen && (
|
{isCategoryModalOpen && (
|
||||||
<CategoryEditModal
|
<Suspense fallback={null}>
|
||||||
category={editingCategory || undefined}
|
<CategoryEditModal
|
||||||
edit={!!editingCategory}
|
category={editingCategory || undefined}
|
||||||
onClose={() => {
|
edit={!!editingCategory}
|
||||||
setEditingCategory(null);
|
onClose={() => {
|
||||||
setIsCategoryModalOpen(false);
|
setEditingCategory(null);
|
||||||
}}
|
setIsCategoryModalOpen(false);
|
||||||
onSave={handleSaveCategory}
|
}}
|
||||||
onDelete={handleDeleteCategory}
|
onSave={handleSaveCategory}
|
||||||
/>
|
onDelete={handleDeleteCategory}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConfigModalOpen && (
|
{isConfigModalOpen && (
|
||||||
<ConfigurationModal
|
<Suspense fallback={null}>
|
||||||
currentConfig={config}
|
<ConfigurationModal
|
||||||
onClose={() => setIsConfigModalOpen(false)}
|
currentConfig={config}
|
||||||
onSave={handleSaveConfig}
|
onClose={() => setIsConfigModalOpen(false)}
|
||||||
onWallpaperChange={handleWallpaperChange}
|
onSave={handleSaveConfig}
|
||||||
onNextWallpaper={handleNextWallpaper}
|
onWallpaperChange={handleWallpaperChange}
|
||||||
/>
|
onNextWallpaper={handleNextWallpaper}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ RUN npm run build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY manifest.json /usr/share/nginx/html/
|
COPY manifest.json /usr/share/nginx/html/
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/gzip.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -16,9 +16,26 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
|||||||
const [time, setTime] = useState(new Date());
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = setInterval(() => setTime(new Date()), 1000);
|
if (!config.clock.enabled) return;
|
||||||
return () => clearInterval(timerId);
|
|
||||||
}, []);
|
const scheduleNext = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
setTime(new Date());
|
||||||
|
intervalId = window.setInterval(() => setTime(new Date()), 60_000);
|
||||||
|
}, msToNextMinute);
|
||||||
|
};
|
||||||
|
|
||||||
|
let timeoutId = 0;
|
||||||
|
let intervalId = 0;
|
||||||
|
scheduleNext();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) window.clearTimeout(timeoutId);
|
||||||
|
if (intervalId) window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [config.clock.enabled]);
|
||||||
|
|
||||||
if (!config.clock.enabled) {
|
if (!config.clock.enabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Server } from '../types';
|
import { Server } from '../types';
|
||||||
import ping from './utils/jsping.js';
|
import ping from './utils/jsping.js';
|
||||||
|
|
||||||
@@ -12,45 +12,64 @@ interface ServerWidgetProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'offline':
|
||||||
|
return 'bg-red-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||||
|
const serversRef = useRef(config.serverWidget.servers);
|
||||||
|
serversRef.current = config.serverWidget.servers;
|
||||||
|
|
||||||
|
const serversSignature = config.serverWidget.servers
|
||||||
|
.map(s => `${s.id}:${s.address}`)
|
||||||
|
.join('|');
|
||||||
|
const serverCount = config.serverWidget.servers.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!config.serverWidget.enabled) return;
|
||||||
|
|
||||||
const pingServers = () => {
|
const pingServers = () => {
|
||||||
config.serverWidget.servers.forEach((server) => {
|
const pending: Record<string, string> = {};
|
||||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'pending' }));
|
for (const s of serversRef.current) pending[s.id] = 'pending';
|
||||||
|
setServerStatus(prev => ({ ...prev, ...pending }));
|
||||||
|
|
||||||
|
serversRef.current.forEach((server) => {
|
||||||
ping(server.address)
|
ping(server.address)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'online' }));
|
setServerStatus(prev =>
|
||||||
|
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'offline' }));
|
setServerStatus(prev =>
|
||||||
|
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.serverWidget.enabled) {
|
pingServers();
|
||||||
pingServers();
|
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
||||||
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
return () => clearInterval(interval);
|
||||||
return () => clearInterval(interval);
|
}, [
|
||||||
}
|
config.serverWidget.enabled,
|
||||||
}, [config.serverWidget.enabled, config.serverWidget.servers, config.serverWidget.pingFrequency]);
|
config.serverWidget.pingFrequency,
|
||||||
|
serversSignature,
|
||||||
|
serverCount,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!config.serverWidget.enabled) {
|
if (!config.serverWidget.enabled) {
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-auto max-w-full">
|
<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"
|
<div className="flex items-center gap-4 bg-black/25 backdrop-blur-md border border-white/20 px-4 py-2 shadow-lg"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { Wallpaper as WallpaperType } from '../types';
|
import { Wallpaper as WallpaperType } from '../types';
|
||||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||||
@@ -13,50 +13,58 @@ interface WallpaperProps {
|
|||||||
wallpaperVersion: number;
|
wallpaperVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseFrequencyToMs = (freq: string): number => {
|
||||||
|
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
||||||
|
const match = freq.match(/(\d+)(h|d)/);
|
||||||
|
if (!match) return 24 * 60 * 60 * 1000;
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
if (unit === 'h') return value * 60 * 60 * 1000;
|
||||||
|
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
||||||
|
return 24 * 60 * 60 * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
if (!name) return undefined;
|
if (!name) return undefined;
|
||||||
|
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||||
|
|
||||||
|
let resolved: string | undefined;
|
||||||
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
if (foundInBase) {
|
if (foundInBase) {
|
||||||
return foundInBase.url || foundInBase.base64;
|
resolved = foundInBase.url || foundInBase.base64;
|
||||||
}
|
} else {
|
||||||
|
try {
|
||||||
try {
|
const storedUserWallpapers: WallpaperType[] =
|
||||||
const storedUserWallpapers: WallpaperType[] =
|
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
if (foundInUser) {
|
||||||
if (foundInUser) {
|
try {
|
||||||
try {
|
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
||||||
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
if (wallpaperData && wallpaperData.startsWith('http')) {
|
||||||
if (wallpaperData && wallpaperData.startsWith('http')) {
|
resolved = wallpaperData;
|
||||||
return wallpaperData;
|
} else {
|
||||||
|
resolved = wallpaperData || undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting wallpaper from chrome storage', error);
|
||||||
|
resolved = undefined;
|
||||||
}
|
}
|
||||||
return wallpaperData || undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting wallpaper from chrome storage', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading userWallpapers from localStorage', error);
|
||||||
|
resolved = undefined;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading userWallpapers from localStorage', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
wallpaperUrlCache.set(name, resolved);
|
||||||
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
|
const resolvedRef = useRef(false);
|
||||||
// Helper to parse wallpaperFrequency string to ms
|
|
||||||
const parseFrequencyToMs = (freq: string): number => {
|
|
||||||
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
|
||||||
const match = freq.match(/(\d+)(h|d)/);
|
|
||||||
if (!match) return 24 * 60 * 60 * 1000;
|
|
||||||
const value = parseInt(match[1], 10);
|
|
||||||
const unit = match[2];
|
|
||||||
if (unit === 'h') return value * 60 * 60 * 1000;
|
|
||||||
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
|
||||||
return 24 * 60 * 60 * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateWallpaper = async () => {
|
const updateWallpaper = async () => {
|
||||||
@@ -111,6 +119,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
resolvedRef.current = true;
|
||||||
setImageUrl(resolvedUrl);
|
setImageUrl(resolvedUrl);
|
||||||
};
|
};
|
||||||
updateWallpaper();
|
updateWallpaper();
|
||||||
@@ -120,14 +129,13 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 -z-10 w-full h-full"
|
className="fixed inset-0 -z-10 w-full h-full wallpaper-transition"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${imageUrl})`,
|
backgroundImage: `url(${imageUrl})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||||
opacity: opacity / 100,
|
opacity: opacity / 100,
|
||||||
transition: 'filter 0.3s, opacity 0.3s',
|
|
||||||
}}
|
}}
|
||||||
aria-label="Wallpaper background"
|
aria-label="Wallpaper background"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import { getWebsiteIcon } from './utils/iconService';
|
import { getWebsiteIcon } from './utils/iconService';
|
||||||
|
|
||||||
@@ -22,47 +22,64 @@ interface IconMetadata {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
colors: any; // this can be anything I guess
|
colors: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let iconMetadataCache: IconMetadata[] | null = null;
|
||||||
|
|
||||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(website ? website.name : '');
|
const [name, setName] = useState(website ? website.name : '');
|
||||||
const [url, setUrl] = useState(website ? website.url : '');
|
const [url, setUrl] = useState(website ? website.url : '');
|
||||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||||
const [iconQuery, setIconQuery] = useState('');
|
const [iconQuery, setIconQuery] = useState('');
|
||||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
|
||||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||||
|
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||||
|
const [iconsFetched, setIconsFetched] = useState(false);
|
||||||
|
const debounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const ensureIconMetadata = () => {
|
||||||
fetch('/icon-metadata.json')
|
if (iconMetadataCache || iconsFetched) return;
|
||||||
|
setIconsFetched(true);
|
||||||
|
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const iconsArray = Object.entries(data).map(([name, details]) => ({
|
const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({
|
||||||
name,
|
name,
|
||||||
...details
|
...(details as object),
|
||||||
}));
|
})) as IconMetadata[];
|
||||||
// Expand colors into separate entries
|
iconMetadataCache = iconsArray;
|
||||||
iconsArray.forEach(icon => {
|
|
||||||
if (icon.colors) {
|
|
||||||
const colors = Object.values(icon.colors).filter(key => key !== icon.name);
|
|
||||||
for (const color of colors) {
|
|
||||||
const newIcon = { ...icon };
|
|
||||||
newIcon.name = color;
|
|
||||||
iconsArray.push(newIcon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setIconMetadata(iconsArray);
|
setIconMetadata(iconsArray);
|
||||||
});
|
})
|
||||||
}, []);
|
.catch(err => console.error('Failed to load icon metadata', err));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (iconQuery && Array.isArray(iconMetadata)) {
|
if (iconQuery && Array.isArray(iconMetadata) && iconMetadata.length > 0) {
|
||||||
const lowerCaseQuery = iconQuery.toLowerCase();
|
if (debounceRef.current) window.clearTimeout(debounceRef.current);
|
||||||
const filtered = iconMetadata
|
debounceRef.current = window.setTimeout(() => {
|
||||||
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery))
|
const lowerCaseQuery = iconQuery.toLowerCase();
|
||||||
.slice(0, 50);
|
const filtered: IconMetadata[] = [];
|
||||||
setFilteredIcons(filtered);
|
for (const ic of iconMetadata) {
|
||||||
|
if (ic.name.toLowerCase().includes(lowerCaseQuery)) {
|
||||||
|
filtered.push(ic);
|
||||||
|
if (filtered.length >= 50) break;
|
||||||
|
}
|
||||||
|
if (ic.colors) {
|
||||||
|
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
|
||||||
|
for (const color of colors) {
|
||||||
|
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
|
||||||
|
filtered.push({ ...ic, name: color });
|
||||||
|
if (filtered.length >= 50) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filtered.length >= 50) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFilteredIcons(filtered);
|
||||||
|
}, 150);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) window.clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
setFilteredIcons([]);
|
setFilteredIcons([]);
|
||||||
}
|
}
|
||||||
@@ -76,7 +93,6 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
console.log({ id: website?.id, name, url, icon });
|
|
||||||
onSave({ id: website?.id, name, url, icon });
|
onSave({ id: website?.id, name, url, icon });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,6 +144,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
setIcon(e.target.value);
|
setIcon(e.target.value);
|
||||||
setIconQuery(e.target.value);
|
setIconQuery(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
onFocus={ensureIconMetadata}
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
||||||
/>
|
/>
|
||||||
{filteredIcons.length > 0 && (
|
{filteredIcons.length > 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
@@ -23,7 +23,6 @@ const getTileSizeClass = (size: string | undefined) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Returns normal icon size in px
|
|
||||||
const getIconPixelSize = (size: string | undefined): number => {
|
const getIconPixelSize = (size: string | undefined): number => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case 'small':
|
case 'small':
|
||||||
@@ -37,7 +36,6 @@ const getIconPixelSize = (size: string | undefined): number => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Returns loading icon size in px
|
|
||||||
const getIconLoadingPixelSize = (size: string | undefined): number => {
|
const getIconLoadingPixelSize = (size: string | undefined): number => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case 'small':
|
case 'small':
|
||||||
@@ -115,4 +113,4 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsiteTile;
|
export default memo(WebsiteTile);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
import WebsiteTile from '../WebsiteTile';
|
import WebsiteTile from '../WebsiteTile';
|
||||||
import { Category, Website } from '../../types';
|
import { Category, Website } from '../../types';
|
||||||
|
|
||||||
@@ -10,10 +11,8 @@ interface CategoryGroupProps {
|
|||||||
setEditingWebsite: (website: Website) => void;
|
setEditingWebsite: (website: Website) => void;
|
||||||
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
||||||
getHorizontalAlignmentClass: (alignment: string) => string;
|
getHorizontalAlignmentClass: (alignment: string) => string;
|
||||||
config: {
|
horizontalAlignment: string;
|
||||||
horizontalAlignment: string;
|
tileSize?: string;
|
||||||
tileSize?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
@@ -25,12 +24,13 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
setEditingWebsite,
|
setEditingWebsite,
|
||||||
handleMoveWebsite,
|
handleMoveWebsite,
|
||||||
getHorizontalAlignmentClass,
|
getHorizontalAlignmentClass,
|
||||||
config,
|
horizontalAlignment,
|
||||||
|
tileSize,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="w-full">
|
<div key={category.id} className="w-full">
|
||||||
<div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
|
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-4 w-full ${horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
|
||||||
<h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
<h2 className={`text-2xl font-bold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -45,7 +45,7 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
|
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-6`}>
|
||||||
{category.websites.map((website) => (
|
{category.websites.map((website) => (
|
||||||
<WebsiteTile
|
<WebsiteTile
|
||||||
key={website.id}
|
key={website.id}
|
||||||
@@ -53,7 +53,7 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
onEdit={setEditingWebsite}
|
onEdit={setEditingWebsite}
|
||||||
onMove={handleMoveWebsite}
|
onMove={handleMoveWebsite}
|
||||||
tileSize={config.tileSize}
|
tileSize={tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
@@ -72,4 +72,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CategoryGroup;
|
export default memo(CategoryGroup);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const getSubtitleSizeClass = (size: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,24 +1,38 @@
|
|||||||
function request_image(url) {
|
function request_image(url) {
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
img.onload = function() { resolve(img); };
|
var settled = false;
|
||||||
img.onerror = function() { reject(url); };
|
function settleOk() {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(failTimer);
|
||||||
|
img.onload = img.onerror = null;
|
||||||
|
resolve(img);
|
||||||
|
}
|
||||||
|
function settleFail() {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(failTimer);
|
||||||
|
img.onload = img.onerror = null;
|
||||||
|
reject(url);
|
||||||
|
}
|
||||||
|
img.onload = settleOk;
|
||||||
|
img.onerror = settleFail;
|
||||||
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16);
|
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16);
|
||||||
|
var failTimer = setTimeout(settleFail, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function ping(url, multiplier) {
|
function ping(url, multiplier) {
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
var start = (new Date()).getTime();
|
var start = (new Date()).getTime();
|
||||||
var response = function() {
|
var response = function() {
|
||||||
var delta = ((new Date()).getTime() - start);
|
var delta = ((new Date()).getTime() - start);
|
||||||
delta *= (multiplier || 1);
|
delta *= (multiplier || 1);
|
||||||
resolve(delta);
|
resolve(delta);
|
||||||
};
|
};
|
||||||
request_image(url).then(response).catch(response);
|
request_image(url).then(response).catch(response);
|
||||||
|
|
||||||
setTimeout(function() { reject(Error('Timeout')); }, 5000);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ping;
|
export default ping;
|
||||||
|
|||||||
@@ -3,4 +3,8 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wallpaper-transition {
|
||||||
|
transition: filter 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|||||||
13
nginx.conf
Normal file
13
nginx.conf
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gzip on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_types
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml
|
||||||
|
text/css
|
||||||
|
text/plain
|
||||||
|
text/xml
|
||||||
|
image/svg+xml;
|
||||||
@@ -36,7 +36,7 @@ Live instances / artifacts:
|
|||||||
| Bundler / dev server | Vite 6 |
|
| Bundler / dev server | Vite 6 |
|
||||||
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
||||||
| Drag & drop | `@hello-pangea/dnd` 18 |
|
| Drag & drop | `@hello-pangea/dnd` 18 |
|
||||||
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle) |
|
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) |
|
||||||
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
||||||
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
||||||
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
||||||
@@ -62,6 +62,15 @@ The startpage is composed of widgets and a configuration panel:
|
|||||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile move/edit buttons, per-category edit buttons, and "add" buttons.
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile move/edit buttons, per-category edit buttons, and "add" buttons.
|
||||||
- **Glassmorphism design language** — Translucent surfaces, `backdrop-blur`, subtle borders, and custom iOS-like easing tokens (`ease-ios`, `ease-spring`) defined in `index.css`.
|
- **Glassmorphism design language** — Translucent surfaces, `backdrop-blur`, subtle borders, and custom iOS-like easing tokens (`ease-ios`, `ease-spring`) defined in `index.css`.
|
||||||
|
|
||||||
|
Performance notes:
|
||||||
|
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
||||||
|
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||||
|
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||||
|
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||||
|
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||||
|
- Icon metadata (`/icon-metadata.json`) is module-level cached, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
||||||
|
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map` and its CSS transition lives in the static `.wallpaper-transition` class in `index.css`.
|
||||||
|
|
||||||
Planned / To-do (tracked in `README.md`):
|
Planned / To-do (tracked in `README.md`):
|
||||||
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
|
|
||||||
@@ -86,7 +95,6 @@ vision-start/
|
|||||||
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
|
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
|
||||||
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||||
│ ├── CategoryEditModal.tsx # Add/edit a category
|
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||||
│ ├── EditModal.tsx # Legacy drag-and-drop editor (imports IconPicker & lucide-react; not wired into App.tsx)
|
|
||||||
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||||
│ ├── ServerWidget.tsx # Bottom server status pill
|
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||||
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||||
@@ -126,7 +134,8 @@ vision-start/
|
|||||||
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
||||||
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
||||||
│
|
│
|
||||||
├── Dockerfile # Node 22 build → nginx serving dist/
|
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
|
||||||
|
├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
||||||
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
||||||
@@ -211,9 +220,8 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
## 8. Notable Behaviors & Quirks
|
## 8. Notable Behaviors & Quirks
|
||||||
|
|
||||||
- **`EditModal.tsx` is not wired up.** It imports `./IconPicker` and `lucide-react`, neither of which exist in `package.json` or this tree. It appears to be a legacy/abandoned editor superseded by `WebsiteEditModal.tsx` + `CategoryEditModal.tsx`. Treat it as dead code unless reintended.
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
||||||
- **`EditModal` references `lucide-react` and `IconPicker`** which are **not** in `package.json`; do not rely on them.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles via simple left/right buttons, not drag-and-drop.
|
||||||
- **`@hello-pangea/dnd`** is used only inside `EditModal.tsx` (legacy) and `ServerWidgetTab.tsx` (server reorder). `WebsiteTile` moves tile via simple left/right buttons, not drag-and-drop.
|
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
||||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||||
|
|||||||
Reference in New Issue
Block a user