Compare commits
18 Commits
d30fd8d30b
...
v0.1.5
Author | SHA1 | Date | |
---|---|---|---|
d4c1884471 | |||
377be6e8f6 | |||
c60cb24dd4 | |||
2f3949c2e3 | |||
9b818b05f9 | |||
008d2321e5 | |||
3aff7ffed6 | |||
9e80818fc5 | |||
140119cb99 | |||
af7b778561 | |||
2849ed3bb2 | |||
f1c1b0c6c6 | |||
ffdaf06d55 | |||
05263d0d3a | |||
905b05e343 | |||
181fd3b3ec | |||
12ed7e1b9f | |||
e6bc95b7e6 |
18
.gitea/workflows/main.yaml
Normal file
18
.gitea/workflows/main.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm install
|
||||||
|
- name: Run build
|
||||||
|
run: npm run build
|
90
.gitea/workflows/release.yaml
Normal file
90
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
zip-file: vision-start-${{ gitea.ref_name }}.zip
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup required tools
|
||||||
|
run: sudo apt-get install zip jq curl -y
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm install
|
||||||
|
- name: Run build
|
||||||
|
run: npm run build
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
bash scripts/prepare_release.sh
|
||||||
|
mv dist vision-start/
|
||||||
|
mv manifest.json vision-start/
|
||||||
|
- name: Create zip archive
|
||||||
|
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-zip
|
||||||
|
path: vision-start-${{ gitea.ref_name }}.zip
|
||||||
|
|
||||||
|
virus-total-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
outputs:
|
||||||
|
analysis-url: ${{ steps.vt-check.outputs.analysis-url }}
|
||||||
|
detection-ratio: ${{ steps.vt-check.outputs.detection-ratio }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup required tools
|
||||||
|
run: sudo apt-get install jq curl -y
|
||||||
|
- name: Download artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-zip
|
||||||
|
- name: Run VirusTotal check
|
||||||
|
id: vt-check
|
||||||
|
env:
|
||||||
|
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
||||||
|
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
||||||
|
run: |
|
||||||
|
# Run the VirusTotal check script and capture output
|
||||||
|
bash scripts/check_virustotal.sh > vt_output.txt 2>&1
|
||||||
|
|
||||||
|
# Extract analysis URL and detection ratio from output
|
||||||
|
ANALYSIS_URL=$(grep "Analysis URL:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
||||||
|
DETECTION_RATIO=$(grep "Detection ratio:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
||||||
|
|
||||||
|
# Set outputs for next job
|
||||||
|
echo "analysis-url=$ANALYSIS_URL" >> $GITEA_OUTPUT
|
||||||
|
echo "detection-ratio=$DETECTION_RATIO" >> $GITEA_OUTPUT
|
||||||
|
|
||||||
|
# Display the full output
|
||||||
|
cat vt_output.txt
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build, virus-total-check]
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Download artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-zip
|
||||||
|
- name: Release zip
|
||||||
|
uses: akkuman/gitea-release-action@v1
|
||||||
|
with:
|
||||||
|
body: |
|
||||||
|
This is the release for version ${{ gitea.ref_name }}.
|
||||||
|
|
||||||
|
**Virus Total Analysis URL:** ${{ needs.virus-total-check.outputs.analysis-url }}
|
||||||
|
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
||||||
|
name: ${{ gitea.ref_name }}
|
||||||
|
tag_name: ${{ gitea.ref_name }}
|
||||||
|
files: vision-start-${{ gitea.ref_name }}.zip
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Project specific files
|
||||||
|
public/icon-metadata.json
|
256
App.tsx
256
App.tsx
@@ -1,27 +1,26 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import WebsiteTile from './components/WebsiteTile';
|
|
||||||
import ConfigurationModal from './components/ConfigurationModal';
|
import ConfigurationModal from './components/ConfigurationModal';
|
||||||
import Clock from './components/Clock';
|
|
||||||
import ServerWidget from './components/ServerWidget';
|
import ServerWidget from './components/ServerWidget';
|
||||||
import { DEFAULT_CATEGORIES } from './constants';
|
import { DEFAULT_CATEGORIES } from './constants';
|
||||||
import { Category, Website, Wallpaper } from './types';
|
import { Category, Website, Config } from './types';
|
||||||
import Dropdown from './components/Dropdown';
|
|
||||||
import WebsiteEditModal from './components/WebsiteEditModal';
|
import WebsiteEditModal from './components/WebsiteEditModal';
|
||||||
import CategoryEditModal from './components/CategoryEditModal';
|
import CategoryEditModal from './components/CategoryEditModal';
|
||||||
import { PlusCircle, Pencil } from 'lucide-react';
|
import Header from './components/layout/Header';
|
||||||
import { baseWallpapers } from './components/utils/baseWallpapers';
|
import EditButton from './components/layout/EditButton';
|
||||||
|
import ConfigurationButton from './components/layout/ConfigurationButton';
|
||||||
|
import CategoryGroup from './components/layout/CategoryGroup';
|
||||||
|
import Wallpaper from './components/Wallpaper';
|
||||||
|
|
||||||
|
const defaultConfig: Config = {
|
||||||
const defaultConfig = {
|
|
||||||
title: 'Vision Start',
|
title: 'Vision Start',
|
||||||
subtitle: 'Your personal portal to the web.',
|
currentWallpapers: ['Abstract'],
|
||||||
backgroundUrl: '/waves.jpg',
|
wallpaperFrequency: '1d',
|
||||||
wallpaperBlur: 0,
|
wallpaperBlur: 0,
|
||||||
wallpaperBrightness: 100,
|
wallpaperBrightness: 100,
|
||||||
wallpaperOpacity: 100,
|
wallpaperOpacity: 100,
|
||||||
titleSize: 'medium',
|
titleSize: 'medium',
|
||||||
subtitleSize: 'medium',
|
|
||||||
alignment: 'middle',
|
alignment: 'middle',
|
||||||
|
horizontalAlignment: 'middle',
|
||||||
clock: {
|
clock: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
size: 'medium',
|
size: 'medium',
|
||||||
@@ -53,41 +52,47 @@ const App: React.FC = () => {
|
|||||||
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||||
const [config, setConfig] = useState(() => {
|
const [config, setConfig] = useState<Config>(() => {
|
||||||
try {
|
try {
|
||||||
const storedConfig = localStorage.getItem('config');
|
const storedConfig = localStorage.getItem('config');
|
||||||
if (storedConfig) {
|
if (storedConfig) {
|
||||||
return { ...defaultConfig, ...JSON.parse(storedConfig) };
|
const parsedConfig = JSON.parse(storedConfig);
|
||||||
|
return { ...defaultConfig, ...parsedConfig };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing config from localStorage', error);
|
console.error('Error parsing config from localStorage', error);
|
||||||
}
|
}
|
||||||
return { ...defaultConfig };
|
return { ...defaultConfig };
|
||||||
});
|
});
|
||||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>(() => {
|
|
||||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
|
||||||
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
|
||||||
const selectedWallpaper = allWallpapers.find(w => w.url === config.backgroundUrl || w.base64 === config.backgroundUrl);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('categories', JSON.stringify(categories));
|
|
||||||
localStorage.setItem('config', JSON.stringify(config));
|
localStorage.setItem('config', JSON.stringify(config));
|
||||||
}, [categories, config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSaveConfig = (newConfig: any) => {
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('categories', JSON.stringify(categories));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving categories to localStorage', error);
|
||||||
|
}
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
const handleSaveConfig = (newConfig: Config) => {
|
||||||
setConfig(newConfig);
|
setConfig(newConfig);
|
||||||
setIsConfigModalOpen(false);
|
setIsConfigModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWallpaperChange = (newConfig: Partial<Config>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...newConfig }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveWebsite = (website: Partial<Website>) => {
|
const handleSaveWebsite = (website: Partial<Website>) => {
|
||||||
if (editingWebsite) {
|
if (editingWebsite) {
|
||||||
|
const idToUpdate = website.id ?? editingWebsite.id;
|
||||||
const newCategories = categories.map(category => ({
|
const newCategories = categories.map(category => ({
|
||||||
...category,
|
...category,
|
||||||
websites: category.websites.map(w =>
|
websites: category.websites.map(w =>
|
||||||
w.id === website.id ? { ...w, ...website } : w
|
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate } : w
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
setCategories(newCategories);
|
setCategories(newCategories);
|
||||||
@@ -149,7 +154,7 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
|
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
|
||||||
const categoryIndex = categories.findIndex(c => c.id === website.categoryId);
|
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
|
||||||
if (categoryIndex === -1) return;
|
if (categoryIndex === -1) return;
|
||||||
|
|
||||||
const category = categories[categoryIndex];
|
const category = categories[categoryIndex];
|
||||||
@@ -190,167 +195,52 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getClockSizeClass = (size: string) => {
|
const getHorizontalAlignmentClass = (alignment: string) => {
|
||||||
switch (size) {
|
switch (alignment) {
|
||||||
case 'tiny':
|
case 'left':
|
||||||
return 'text-3xl';
|
return 'justify-start';
|
||||||
case 'small':
|
case 'middle':
|
||||||
return 'text-4xl';
|
return 'justify-center';
|
||||||
case 'medium':
|
case 'right':
|
||||||
return 'text-5xl';
|
return 'justify-end';
|
||||||
case 'large':
|
|
||||||
return 'text-6xl';
|
|
||||||
default:
|
default:
|
||||||
return 'text-5xl';
|
return 'justify-center';
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitleSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-4xl';
|
|
||||||
case 'small':
|
|
||||||
return 'text-5xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-6xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-7xl';
|
|
||||||
default:
|
|
||||||
return 'text-6xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-lg';
|
|
||||||
case 'small':
|
|
||||||
return 'text-xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-2xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-3xl';
|
|
||||||
default:
|
|
||||||
return 'text-2xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTileSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
|
className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
|
||||||
>
|
>
|
||||||
<div
|
<Wallpaper
|
||||||
className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
|
wallpaperNames={config.currentWallpapers}
|
||||||
style={{
|
blur={config.wallpaperBlur}
|
||||||
backgroundImage: `url('${selectedWallpaper?.url || selectedWallpaper?.base64 || ''}')`,
|
brightness={config.wallpaperBrightness}
|
||||||
filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
|
opacity={config.wallpaperOpacity}
|
||||||
opacity: `${config.wallpaperOpacity}%`,
|
wallpaperFrequency={config.wallpaperFrequency}
|
||||||
}}
|
/>
|
||||||
></div>
|
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
|
||||||
<div className="absolute top-4 left-4">
|
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
|
||||||
<button
|
|
||||||
onClick={() => setIsEditing(!isEditing)}
|
|
||||||
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
|
||||||
</svg>
|
|
||||||
{isEditing ? 'Done' : 'Edit'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-4 right-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsConfigModalOpen(true)}
|
|
||||||
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white hover:bg-white/25 transition-colors"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-gear-wide" viewBox="0 0 16 16">
|
|
||||||
<path d="M8.932.727c-.243-.97-1.62-.97-1.864 0l-.071.286a.96.96 0 0 1-1.622.434l-.205-.211c-.695-.719-1.888-.03-1.613.931l.08.284a.96.96 0 0 1-1.186 1.187l-.284-.081c-.96-.275-1.65.918-.931 1.613l.211.205a.96.96 0 0 1-.434 1.622l-.286.071c-.97.243-.97 1.62 0 1.864l.286.071a.96.96 0 0 1 .434 1.622l-.211.205c-.719.695-.03 1.888.931 1.613l.284-.08a.96.96 0 0 1 1.187 1.187l-.081.283c-.275.96.918 1.65 1.613.931l.205-.211a.96.96 0 0 1 1.622.434l.071.286c.243.97 1.62.97 1.864 0l.071-.286a.96.96 0 0 1 1.622-.434l.205.211c.695.719 1.888.03 1.613-.931l-.08-.284a.96.96 0 0 1 1.187-1.187l.283.081c.96.275 1.65-.918.931-1.613l-.211-.205a.96.96 0 0 1 .434-1.622l.286-.071c.97-.243.97-1.62 0-1.864l-.286-.071a.96.96 0 0 1-.434-1.622l.211-.205c.719-.695.03-1.888-.931-1.613l-.284.08a.96.96 0 0 1-1.187-1.186l.081-.284c.275-.96-.918-1.65-1.613-.931l-.205.211a.96.96 0 0 1-1.622-.434zM8 12.997a4.998 4.998 0 1 1 0-9.995 4.998 4.998 0 0 1 0 9.996z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Absolute top-center Clock */}
|
<Header config={config} />
|
||||||
{config.clock.enabled && (
|
|
||||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
|
|
||||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
|
||||||
{config.title || config.subtitle &&
|
|
||||||
(
|
|
||||||
<div className="text-center">
|
|
||||||
<h1
|
|
||||||
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
|
|
||||||
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{config.title}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
|
|
||||||
style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{config.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-8 w-full mt-16">
|
<div className="flex flex-col gap-8 w-full mt-16">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category.id} className="w-full">
|
<CategoryGroup
|
||||||
<div className="flex justify-center items-center mb-4">
|
key={category.id}
|
||||||
<h2 className="text-2xl font-bold text-white text-center">{category.name}</h2>
|
category={category}
|
||||||
{isEditing && (
|
isEditing={isEditing}
|
||||||
<button
|
setEditingCategory={setEditingCategory}
|
||||||
onClick={() => {
|
setIsCategoryModalOpen={setIsCategoryModalOpen}
|
||||||
setEditingCategory(category);
|
setAddingWebsite={setAddingWebsite}
|
||||||
setIsCategoryModalOpen(true);
|
setEditingWebsite={setEditingWebsite}
|
||||||
}}
|
handleMoveWebsite={handleMoveWebsite}
|
||||||
className="ml-2 text-white/50 hover:text-white transition-colors"
|
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
||||||
>
|
config={config}
|
||||||
<Pencil size={20} />
|
/>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap justify-center gap-6">
|
|
||||||
{category.websites.map((website) => (
|
|
||||||
<WebsiteTile
|
|
||||||
key={website.id}
|
|
||||||
website={website}
|
|
||||||
isEditing={isEditing}
|
|
||||||
onEdit={setEditingWebsite}
|
|
||||||
onMove={handleMoveWebsite}
|
|
||||||
className={getTileSizeClass(config.tileSize)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{isEditing && (
|
|
||||||
<button
|
|
||||||
onClick={() => setAddingWebsite(category)}
|
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<PlusCircle size={48} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="flex justify-center">
|
<div className={`flex justify-center transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
@@ -358,17 +248,16 @@ const App: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
className="text-white/50 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<PlusCircle size={48} />
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.serverWidget.enabled && (
|
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
||||||
<div className="absolute bottom-4 right-4">
|
|
||||||
<ServerWidget config={config} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(editingWebsite || addingWebsite) && (
|
{(editingWebsite || addingWebsite) && (
|
||||||
<WebsiteEditModal
|
<WebsiteEditModal
|
||||||
@@ -397,14 +286,15 @@ const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isConfigModalOpen && (
|
{isConfigModalOpen && (
|
||||||
<ConfigurationModal
|
<ConfigurationModal
|
||||||
currentConfig={config}
|
currentConfig={config}
|
||||||
onClose={() => setIsConfigModalOpen(false)}
|
onClose={() => setIsConfigModalOpen(false)}
|
||||||
onSave={handleSaveConfig}
|
onSave={handleSaveConfig}
|
||||||
|
onWallpaperChange={handleWallpaperChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
39
GEMINI.md
Normal file
39
GEMINI.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Vision Startpage Project
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This project is a highly customizable and stylish startpage built with React. The goal is to create a visually appealing and functional dashboard that serves as a user's entry point to the web.
|
||||||
|
|
||||||
|
## Key Features & Design Principles
|
||||||
|
|
||||||
|
* **Technology Stack:** The project is built using React and TypeScript.
|
||||||
|
* **Aesthetics:** The user interface should have a modern, "glassy" or "frosted glass" look (neumorphism/glassmorphism). This involves using transparency, blur effects, and subtle shadows to create a sense of depth.
|
||||||
|
* **Typography:** Specific font families and types will be used to maintain a consistent and elegant design.
|
||||||
|
* **Modals:** All modals in the application should follow a specific and consistent design language, contributing to the overall user experience.
|
||||||
|
* **Production Quality Code:** All code must be written to production standards, with a strong emphasis on readability, maintainability, and performance.
|
||||||
|
* **Creative & Beautiful Code:** Code should not only be functional but also well-structured, elegant, and creative.
|
||||||
|
|
||||||
|
* **Dropdown Component:** A reusable dropdown component (`components/Dropdown.tsx`) has been created for consistent styling and functionality across the application. It features a dark, glassy look with a custom arrow icon.
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```typescript jsx
|
||||||
|
import Dropdown from './components/Dropdown';
|
||||||
|
|
||||||
|
// ... inside a React component
|
||||||
|
<Dropdown
|
||||||
|
options={[
|
||||||
|
{ value: 'option1', label: 'Option 1' },
|
||||||
|
{ value: 'option2', label: 'Option 2' },
|
||||||
|
]}
|
||||||
|
value={selectedValue}
|
||||||
|
onChange={handleSelectChange}
|
||||||
|
name="myDropdown"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
* Follow the existing code style and conventions.
|
||||||
|
* Ensure all new components and features align with the established design principles.
|
||||||
|
* Write clean, commented, and reusable code.
|
||||||
|
* DO NOT run `npm run dev`, and instead, run `npm run build`.
|
95
README.md
95
README.md
@@ -1,17 +1,90 @@
|
|||||||
# Vision Start
|
# Vision Start
|
||||||
#### Small startpage
|
#### A glassmorphism-looking like, modern and customizable startpage built with React.
|
||||||
|
|
||||||
## Predefined themes
|
## Screenshots
|
||||||
|
|
||||||
1. Abstract
|

|
||||||
2. Aurora (Vista vibes)
|

|
||||||
3. Mountain
|

|
||||||
|
|
||||||
## Run Locally
|
## Installing
|
||||||
|
|
||||||
**Prerequisites:** Node.js
|
Vision Start is not yet available on Chrome Web Store, but it can be installed manually:
|
||||||
|
1. Go to https://git.ivanch.me/ivanch/vision-start/releases/latest
|
||||||
|
2. Download the latest `vision-start-[version].zip` file
|
||||||
|
3. Extract the zip file, you will have a `vision-start` folder
|
||||||
|
4. Go to chrome://extensions/
|
||||||
|
5. Enable "Developer mode" in the top right corner
|
||||||
|
6. Click on "Load unpacked" and select the `vision-start` folder you extracted in step 3
|
||||||
|
7. The extension should now be installed! Just open a new tab to see it in action.
|
||||||
|
|
||||||
1. Install dependencies:
|
## Features
|
||||||
`npm install`
|
|
||||||
2. Run the app:
|
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
|
||||||
`npm run dev`
|
* **Elegant Clock:** A clock because all startpages have one.
|
||||||
|
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
||||||
|
* **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.
|
||||||
|
* **Future**: a long to do list :(
|
||||||
|
|
||||||
|
## Backgrounds
|
||||||
|
|
||||||
|
It comes with a selection of some nice pre-defined backgrounds: **Abstract**, **Abstract Red**, **Beach**, **Dark**, **Mountain**, **Waves**.
|
||||||
|
|
||||||
|
You can also upload your own images on it (or fetch it from the web).
|
||||||
|
|
||||||
|
## Running Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.com/ivan/vision-start.git
|
||||||
|
cd vision-start
|
||||||
|
```
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. Run the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## To-do
|
||||||
|
|
||||||
|
* [x] Multiple Wallpapers
|
||||||
|
* [x] Remake icons
|
||||||
|
* [/] Increase offline compatibility (might not be possible)
|
||||||
|
- [x] Use chrome.storage.local for user wallpapers -- this one is
|
||||||
|
- [ ] Use chrome.storage.local for some logos -- a bit hard
|
||||||
|
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
|
||||||
|
* Dynamic Weather Widget
|
||||||
|
* A box with information about the current weather, with manual entry on the location
|
||||||
|
* Display current temperature, weather condition (e.g., "Sunny," "Cloudy"), and a corresponding icon
|
||||||
|
* Optionally, show a 3-day forecast when clicked or hovered
|
||||||
|
* Search Bar Widget
|
||||||
|
* Positioned to the right or left side of the clock, display a nice search bar
|
||||||
|
* Behaviour:
|
||||||
|
* When not in focus, it could be highly transparent with just a faint border and a search icon.
|
||||||
|
* When clicked, it would smoothly expand and become slightly more opaque, with a soft glow around the border (similar to the existing ones)
|
||||||
|
* Config to allow changing the default search engine
|
||||||
|
* Draggable & Resizable Grid System
|
||||||
|
* Allow users to drag and drop all widgets (Clock, Website Tiles, Weather, Title, etc.) into any position on a grid
|
||||||
|
* Notes / Scratchpad Widget
|
||||||
|
* A simple text area that saves its content to local storage automatically.
|
||||||
|
* Maybe some extra formatting (bold, italic, increase font size, etc).
|
||||||
|
* Theme-ing
|
||||||
|
* A Light/Dark Mode toggle
|
||||||
|
* Custom Accent Colors
|
||||||
|
* Selection of 6-8 accent colors that are guaranteed to look good with both Light and Dark themes
|
||||||
|
* Define CSS variables for the accent color
|
||||||
|
* Dynamic Wallpaper-Based Theming
|
||||||
|
* Automatically adapt the UI's accent color to match the current wallpaper
|
||||||
|
* Minimal feel toggle
|
||||||
|
* Disable title and search widget
|
||||||
|
* Tiles become small stylish lines
|
||||||
|
|
||||||
|
From a technical side:
|
||||||
|
* Refactor everything :(
|
||||||
|
* Add small nginx demo (with docker)
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Category } from '../types';
|
import { Category } from '../types';
|
||||||
|
|
||||||
interface CategoryEditModalProps {
|
interface CategoryEditModalProps {
|
||||||
@@ -12,10 +12,6 @@ interface CategoryEditModalProps {
|
|||||||
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(category ? category.name : '');
|
const [name, setName] = useState(category ? category.name : '');
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -44,7 +40,7 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={() => onSave(name)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
||||||
|
@@ -1,25 +1,26 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import ToggleSwitch from './ToggleSwitch';
|
import ToggleSwitch from './ToggleSwitch';
|
||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
import { Server, Wallpaper } from '../types';
|
import { Server, Wallpaper } from '../types';
|
||||||
import { Trash } from 'lucide-react';
|
|
||||||
import Dropdown from './Dropdown';
|
import Dropdown from './Dropdown';
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
|
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||||
|
|
||||||
interface ConfigurationModalProps {
|
interface ConfigurationModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (config: any) => void;
|
onSave: (config: any) => void;
|
||||||
currentConfig: any;
|
currentConfig: any;
|
||||||
|
onWallpaperChange: (newConfig: Partial<any>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig }) => {
|
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig, onWallpaperChange }) => {
|
||||||
const [config, setConfig] = useState({
|
const [config, setConfig] = useState({
|
||||||
...currentConfig,
|
...currentConfig,
|
||||||
titleSize: currentConfig.titleSize || 'medium',
|
titleSize: currentConfig.titleSize || 'medium',
|
||||||
subtitleSize: currentConfig.subtitleSize || 'medium',
|
|
||||||
alignment: currentConfig.alignment || 'middle',
|
alignment: currentConfig.alignment || 'middle',
|
||||||
tileSize: currentConfig.tileSize || 'medium',
|
tileSize: currentConfig.tileSize || 'medium',
|
||||||
|
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
|
||||||
wallpaperBlur: currentConfig.wallpaperBlur || 0,
|
wallpaperBlur: currentConfig.wallpaperBlur || 0,
|
||||||
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
|
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
|
||||||
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
|
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
|
||||||
@@ -36,6 +37,10 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
format: 'h:mm A',
|
format: 'h:mm A',
|
||||||
...currentConfig.clock,
|
...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 [newServerName, setNewServerName] = useState('');
|
||||||
@@ -43,11 +48,14 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||||
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isSaving = useRef(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
||||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
||||||
if (storedUserWallpapers) {
|
if (storedUserWallpapers) {
|
||||||
setUserWallpapers(JSON.parse(storedUserWallpapers));
|
setUserWallpapers(JSON.parse(storedUserWallpapers));
|
||||||
@@ -55,23 +63,33 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A small timeout to allow the component to mount before starting the transition
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
}, 10);
|
}, 10);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!isSaving.current) {
|
||||||
|
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
}, 300); // This duration should match the transition duration
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
if (name.startsWith('serverWidget.')) {
|
if (name === 'currentWallpapers') {
|
||||||
|
const wallpaperNames = Array.isArray(value) ? value : [value];
|
||||||
|
setConfig({ ...config, currentWallpapers: wallpaperNames });
|
||||||
|
} else if (name.startsWith('serverWidget.')) {
|
||||||
const field = name.split('.')[1];
|
const field = name.split('.')[1];
|
||||||
setConfig({
|
setConfig({
|
||||||
...config,
|
...config,
|
||||||
@@ -88,6 +106,15 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
||||||
|
// Set wallpaperState in localStorage with lastWallpaperChange datetime
|
||||||
|
localStorage.setItem('wallpaperState', JSON.stringify({
|
||||||
|
lastWallpaperChange: new Date().toISOString(),
|
||||||
|
currentIndex: 0,
|
||||||
|
}));
|
||||||
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
const handleClockToggleChange = (checked: boolean) => {
|
const handleClockToggleChange = (checked: boolean) => {
|
||||||
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
|
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
|
||||||
};
|
};
|
||||||
@@ -146,21 +173,21 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaper = () => {
|
const handleAddWallpaper = async () => {
|
||||||
if (newWallpaperName.trim() === '' || newWallpaperUrl.trim() === '') return;
|
if (newWallpaperUrl.trim() === '') return;
|
||||||
|
try {
|
||||||
const newWallpaper: Wallpaper = {
|
const finalName = await addWallpaperToChromeStorageLocal(newWallpaperName, newWallpaperUrl);
|
||||||
name: newWallpaperName,
|
const newWallpaper: Wallpaper = { name: finalName };
|
||||||
url: newWallpaperUrl,
|
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
||||||
};
|
setUserWallpapers(updatedUserWallpapers);
|
||||||
|
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
setNewWallpaperName('');
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
setNewWallpaperUrl('');
|
||||||
setConfig({ ...config, backgroundUrl: newWallpaperUrl });
|
} catch (error) {
|
||||||
|
alert('Error adding wallpaper. Please check the URL and try again.');
|
||||||
setNewWallpaperName('');
|
console.error(error);
|
||||||
setNewWallpaperUrl('');
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -170,38 +197,42 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
alert('File size exceeds 4MB. Please choose a smaller file.');
|
alert('File size exceeds 4MB. Please choose a smaller file.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = async () => {
|
||||||
const base64 = reader.result as string;
|
const base64 = reader.result as string;
|
||||||
if (base64.length > 4.5 * 1024 * 1024) {
|
if (base64.length > 4.5 * 1024 * 1024) {
|
||||||
alert('The uploaded image is too large. Please choose a smaller file.');
|
alert('The uploaded image is too large. Please choose a smaller file.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const updatedUserWallpapers = userWallpapers.filter(w => !w.base64);
|
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||||
const newWallpaper: Wallpaper = {
|
const newWallpaper: Wallpaper = { name: finalName };
|
||||||
name: file.name,
|
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
||||||
base64,
|
setUserWallpapers(updatedUserWallpapers);
|
||||||
};
|
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||||
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
|
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
|
} catch (error) {
|
||||||
setConfig({ ...config, backgroundUrl: base64 });
|
alert('Error adding wallpaper. Please try again.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
|
const handleDeleteUserWallpaper = async (wallpaper: Wallpaper) => {
|
||||||
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
|
try {
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
|
||||||
|
setUserWallpapers(updatedUserWallpapers);
|
||||||
if (config.backgroundUrl === (wallpaper.url || wallpaper.base64)) {
|
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||||
const nextWallpaper = baseWallpapers[0] || updatedUserWallpapers[0];
|
const newcurrentWallpapers = config.currentWallpapers.filter((name: string) => name !== wallpaper.name);
|
||||||
if (nextWallpaper) {
|
const newConfig = { ...config, currentWallpapers: newcurrentWallpapers };
|
||||||
setConfig({ ...config, backgroundUrl: nextWallpaper.url || nextWallpaper.base64 });
|
setConfig(newConfig);
|
||||||
}
|
onWallpaperChange({ currentWallpapers: newcurrentWallpapers });
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error deleting wallpaper. Please try again.');
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,30 +309,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-slate-300 text-sm font-semibold mb-2 block">Subtitle</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="subtitle"
|
|
||||||
value={config.subtitle}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Subtitle Size</label>
|
|
||||||
<Dropdown
|
|
||||||
name="subtitleSize"
|
|
||||||
value={config.subtitleSize}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -328,6 +335,19 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -336,30 +356,34 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="backgroundUrl"
|
name="currentWallpapers"
|
||||||
value={config.backgroundUrl}
|
value={config.currentWallpapers}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
options={allWallpapers.map(w => ({
|
multiple
|
||||||
value: w.url || w.base64 || '',
|
options={allWallpapers.map(w => ({
|
||||||
label: (
|
value: w.name,
|
||||||
<div className="flex items-center justify-between">
|
label: w.name
|
||||||
{w.name}
|
|
||||||
{!baseWallpapers.includes(w) && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteWallpaper(w);
|
|
||||||
}}
|
|
||||||
className="text-red-500 hover:text-red-400 ml-4"
|
|
||||||
>
|
|
||||||
<Trash size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{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">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -405,48 +429,71 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
<span>{config.wallpaperOpacity}%</span>
|
<span>{config.wallpaperOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{chromeStorageAvailable && (
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div>
|
||||||
<input
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
type="text"
|
<div className="flex flex-col gap-2">
|
||||||
placeholder="Wallpaper Name"
|
{userWallpapers.map((wallpaper) => (
|
||||||
value={newWallpaperName}
|
<div key={wallpaper.name} className="flex items-center justify-between bg-white/10 p-2 rounded-lg">
|
||||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
<button
|
||||||
/>
|
onClick={() => handleDeleteUserWallpaper(wallpaper)}
|
||||||
<div className="flex gap-2">
|
className="text-red-500 hover:text-red-400"
|
||||||
<input
|
>
|
||||||
type="text"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
|
||||||
placeholder="Image URL"
|
<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"/>
|
||||||
value={newWallpaperUrl}
|
<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"/>
|
||||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
</svg>
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
</button>
|
||||||
/>
|
</div>
|
||||||
<button
|
))}
|
||||||
onClick={handleAddWallpaper}
|
</div>
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center w-full">
|
<div>
|
||||||
<label
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
||||||
htmlFor="file-upload"
|
<div className="flex flex-col gap-2">
|
||||||
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"
|
<input
|
||||||
>
|
type="text"
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
placeholder="Wallpaper Name (optional for URLs)"
|
||||||
<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">
|
value={newWallpaperName}
|
||||||
<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"/>
|
onChange={(e) => setNewWallpaperName(e.target.value)}
|
||||||
</svg>
|
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
<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 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>
|
||||||
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
|
<div className="flex items-center justify-center w-full">
|
||||||
</label>
|
<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>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -595,7 +642,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-8 border-t border-white/10">
|
<div className="p-8 border-t border-white/10">
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<button onClick={() => onSave(config)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||||
Save & Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
||||||
@@ -608,4 +655,4 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConfigurationModal;
|
export default ConfigurationModal;
|
@@ -2,17 +2,16 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
options: { value: string; label: string }[];
|
options: { value: string; label: string }[];
|
||||||
value: string;
|
value: string | string[];
|
||||||
onChange: (e: { target: { name: string; value: string } }) => void;
|
onChange: (e: { target: { name: string; value: string | string[] } }) => void;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
multiple?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...rest }) => {
|
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, multiple = false, ...rest }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const selectedOptionLabel = options.find(option => option.value === value)?.label || '';
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
@@ -26,14 +25,46 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOptionClick = (optionValue: string) => {
|
const handleOptionClick = (optionValue: string) => {
|
||||||
|
let newValue: string | string[];
|
||||||
|
if (multiple) {
|
||||||
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
if (currentValues.includes(optionValue)) {
|
||||||
|
newValue = currentValues.filter((v) => v !== optionValue);
|
||||||
|
} else {
|
||||||
|
newValue = [...currentValues, optionValue];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newValue = optionValue;
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
const syntheticEvent = {
|
const syntheticEvent = {
|
||||||
target: {
|
target: {
|
||||||
name: name || '',
|
name: name || '',
|
||||||
value: optionValue,
|
value: newValue,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
onChange(syntheticEvent);
|
onChange(syntheticEvent as any);
|
||||||
setIsOpen(false);
|
};
|
||||||
|
|
||||||
|
const selectedOptionLabel = (() => {
|
||||||
|
if (multiple) {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
if (value.length === 1) {
|
||||||
|
return options.find((o) => o.value === value[0])?.label || '';
|
||||||
|
}
|
||||||
|
return `${value.length} selected`;
|
||||||
|
}
|
||||||
|
return 'Select...';
|
||||||
|
}
|
||||||
|
return options.find((option) => option.value === value)?.label || 'Select...';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isSelected = (optionValue: string) => {
|
||||||
|
if (multiple && Array.isArray(value)) {
|
||||||
|
return value.includes(optionValue);
|
||||||
|
}
|
||||||
|
return optionValue === value;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,12 +103,13 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => handleOptionClick(option.value)}
|
onClick={() => handleOptionClick(option.value)}
|
||||||
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
|
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
|
||||||
${option.value === value
|
${
|
||||||
? 'bg-cyan-500/20 text-cyan-300'
|
isSelected(option.value)
|
||||||
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
|
? 'bg-cyan-500/20 text-cyan-300'
|
||||||
|
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={option.value === value}
|
aria-selected={isSelected(option.value)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{option.label}</span>
|
<span className="truncate">{option.label}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -86,7 +118,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden input to mimic native select behavior for forms */}
|
{/* Hidden input to mimic native select behavior for forms */}
|
||||||
{name && <input type="hidden" name={name} value={value} />}
|
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@@ -1,48 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { icons } from 'lucide-react';
|
|
||||||
|
|
||||||
interface IconPickerProps {
|
|
||||||
onSelect: (iconName: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IconPicker: React.FC<IconPickerProps> = ({ onSelect }) => {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const filteredIcons = useMemo(() => {
|
|
||||||
if (!search) {
|
|
||||||
return Object.keys(icons).slice(0, 50);
|
|
||||||
}
|
|
||||||
return Object.keys(icons).filter(name =>
|
|
||||||
name.toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-800 p-4 rounded-lg">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search for an icon..."
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
className="w-full p-2 mb-4 bg-gray-700 rounded text-white"
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-6 gap-4 max-h-60 overflow-y-auto">
|
|
||||||
{filteredIcons.map(iconName => {
|
|
||||||
const LucideIcon = icons[iconName as keyof typeof icons];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={iconName}
|
|
||||||
onClick={() => onSelect(iconName)}
|
|
||||||
className="cursor-pointer flex flex-col items-center justify-center p-2 rounded-lg hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<LucideIcon color="white" size={24} />
|
|
||||||
<span className="text-xs text-white mt-1">{iconName}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IconPicker;
|
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Server } from '../types';
|
import { Server } from '../types';
|
||||||
import ping from './utils/jsping.js';
|
import ping from './utils/jsping.js';
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
|
106
components/Wallpaper.tsx
Normal file
106
components/Wallpaper.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
|
import { Wallpaper as WallpaperType } from '../types';
|
||||||
|
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||||
|
|
||||||
|
interface WallpaperProps {
|
||||||
|
wallpaperNames: string[];
|
||||||
|
blur: number;
|
||||||
|
brightness: number;
|
||||||
|
opacity: number;
|
||||||
|
wallpaperFrequency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
|
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
|
if (foundInBase) {
|
||||||
|
return foundInBase.url || foundInBase.base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userWallpapers: WallpaperType[] = JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||||
|
const foundInUser = userWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
|
if (foundInUser) {
|
||||||
|
try {
|
||||||
|
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
||||||
|
if (wallpaperData && wallpaperData.startsWith('http')) {
|
||||||
|
return wallpaperData;
|
||||||
|
}
|
||||||
|
return wallpaperData || undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting wallpaper from chrome storage', error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency }) => {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
|
const [currentWallpaperIndex, setCurrentWallpaperIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
const updateWallpaper = async () => {
|
||||||
|
if (wallpaperNames.length === 0) return;
|
||||||
|
// Read wallpaperState from localStorage
|
||||||
|
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||||
|
const lastChange = wallpaperState.lastWallpaperChange ? new Date(wallpaperState.lastWallpaperChange).getTime() : 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
||||||
|
let currentIndex = typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||||
|
|
||||||
|
// If enough time has passed, pick a new wallpaper
|
||||||
|
if (now - lastChange >= freqMs) {
|
||||||
|
currentIndex = (currentIndex + 1) % wallpaperNames.length;
|
||||||
|
localStorage.setItem('wallpaperState', JSON.stringify({
|
||||||
|
lastWallpaperChange: new Date().toISOString(),
|
||||||
|
currentIndex
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Keep currentIndex in sync with localStorage if not updating
|
||||||
|
localStorage.setItem('wallpaperState', JSON.stringify({
|
||||||
|
lastWallpaperChange: wallpaperState.lastWallpaperChange || new Date().toISOString(),
|
||||||
|
currentIndex
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setCurrentWallpaperIndex(currentIndex);
|
||||||
|
const wallpaperName = wallpaperNames[currentIndex];
|
||||||
|
const url = await getWallpaperUrlByName(wallpaperName);
|
||||||
|
setImageUrl(url);
|
||||||
|
};
|
||||||
|
updateWallpaper();
|
||||||
|
// No timer, just run on render/dependency change
|
||||||
|
}, [wallpaperNames, wallpaperFrequency]);
|
||||||
|
|
||||||
|
if (!imageUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 -z-10 w-full h-full"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${imageUrl})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||||
|
opacity: opacity / 100,
|
||||||
|
transition: 'filter 0.3s, opacity 0.3s',
|
||||||
|
}}
|
||||||
|
aria-label="Wallpaper background"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Wallpaper;
|
@@ -1,8 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import IconPicker from './IconPicker';
|
|
||||||
import { getWebsiteIcon } from './utils/iconService';
|
import { getWebsiteIcon } from './utils/iconService';
|
||||||
import { icons } from 'lucide-react';
|
|
||||||
|
|
||||||
interface WebsiteEditModalProps {
|
interface WebsiteEditModalProps {
|
||||||
website?: Website;
|
website?: Website;
|
||||||
@@ -12,23 +10,73 @@ interface WebsiteEditModalProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IconMetadata {
|
||||||
|
name: string;
|
||||||
|
base: string;
|
||||||
|
aliases: string[];
|
||||||
|
categories: string[];
|
||||||
|
update: {
|
||||||
|
timestamp: string;
|
||||||
|
author: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
colors: any; // this can be anything I guess
|
||||||
|
}
|
||||||
|
|
||||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(website ? website.name : '');
|
const [name, setName] = useState(website ? website.name : '');
|
||||||
const [url, setUrl] = useState(website ? website.url : '');
|
const [url, setUrl] = useState(website ? website.url : '');
|
||||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
const [iconQuery, setIconQuery] = useState('');
|
||||||
|
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
||||||
|
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchIcon = async () => {
|
fetch('/icon-metadata.json')
|
||||||
if (url) {
|
.then(response => response.json())
|
||||||
const fetchedIcon = await getWebsiteIcon(url);
|
.then(data => {
|
||||||
setIcon(fetchedIcon);
|
const iconsArray = Object.entries(data).map(([name, details]) => ({
|
||||||
}
|
name,
|
||||||
};
|
...details
|
||||||
fetchIcon();
|
}));
|
||||||
}, [url]);
|
// Expand colors into separate entries
|
||||||
|
iconsArray.forEach(icon => {
|
||||||
|
if (icon.colors) {
|
||||||
|
const colors = Object.values(icon.colors).filter(key => key !== icon.name);
|
||||||
|
for (const color of colors) {
|
||||||
|
const newIcon = { ...icon };
|
||||||
|
newIcon.name = color;
|
||||||
|
iconsArray.push(newIcon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setIconMetadata(iconsArray);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (iconQuery && Array.isArray(iconMetadata)) {
|
||||||
|
const lowerCaseQuery = iconQuery.toLowerCase();
|
||||||
|
const filtered = iconMetadata
|
||||||
|
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery))
|
||||||
|
.slice(0, 50);
|
||||||
|
setFilteredIcons(filtered);
|
||||||
|
} else {
|
||||||
|
setFilteredIcons([]);
|
||||||
|
}
|
||||||
|
}, [iconQuery, iconMetadata]);
|
||||||
|
|
||||||
|
const fetchIcon = async () => {
|
||||||
|
if (url) {
|
||||||
|
const fetchedIcon = await getWebsiteIcon(url);
|
||||||
|
setIcon(fetchedIcon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
console.log({ id: website?.id, name, url, icon });
|
||||||
onSave({ id: website?.id, name, url, icon });
|
onSave({ id: website?.id, name, url, icon });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,18 +86,22 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const LucideIcon = icons[icon as keyof typeof icons];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
||||||
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
|
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
|
||||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{LucideIcon ? (
|
{icon ? (
|
||||||
<LucideIcon className="h-24 w-24 text-white" />
|
|
||||||
) : (
|
|
||||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||||
|
) : (
|
||||||
|
<div className="h-24 w-24 bg-white/10 rounded-lg flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -67,25 +119,44 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<div className="relative w-full">
|
||||||
type="text"
|
<input
|
||||||
placeholder="Icon URL or name"
|
type="text"
|
||||||
value={icon}
|
placeholder="Icon URL or name"
|
||||||
onChange={(e) => setIcon(e.target.value)}
|
value={icon}
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
onChange={(e) => {
|
||||||
/>
|
setIcon(e.target.value);
|
||||||
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
setIconQuery(e.target.value);
|
||||||
{showIconPicker ? 'Close' : 'Select Icon'}
|
}}
|
||||||
|
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
||||||
|
/>
|
||||||
|
{filteredIcons.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full bg-gray-800 rounded-lg mt-1 max-h-60 overflow-y-auto">
|
||||||
|
{filteredIcons.map(iconData => (
|
||||||
|
<div
|
||||||
|
key={iconData.name}
|
||||||
|
onClick={() => {
|
||||||
|
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||||
|
setIcon(iconUrl);
|
||||||
|
setFilteredIcons([]);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer flex items-center p-2 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
||||||
|
alt={iconData.name}
|
||||||
|
className="h-6 w-6 mr-2"
|
||||||
|
/>
|
||||||
|
<span>{iconData.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchIcon} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
||||||
|
Fetch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showIconPicker && (
|
|
||||||
<IconPicker
|
|
||||||
onSelect={(iconName) => {
|
|
||||||
setIcon(iconName);
|
|
||||||
setShowIconPicker(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-8">
|
<div className="flex justify-between items-center mt-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -109,4 +180,4 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsiteEditModal;
|
export default WebsiteEditModal;
|
@@ -1,42 +1,114 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import { icons, ArrowLeft, ArrowRight, Pencil } from 'lucide-react';
|
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
website: Website;
|
website: Website;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
onEdit: (website: Website) => void;
|
onEdit: (website: Website) => void;
|
||||||
onMove: (website: Website, direction: 'left' | 'right') => void;
|
onMove: (website: Website, direction: 'left' | 'right') => void;
|
||||||
className?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, className }) => {
|
const getTileSizeClass = (size: string | undefined) => {
|
||||||
const LucideIcon = icons[website.icon as keyof typeof icons];
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return 'w-28 h-28';
|
||||||
|
case 'medium':
|
||||||
|
return 'w-32 h-32';
|
||||||
|
case 'large':
|
||||||
|
return 'w-36 h-36';
|
||||||
|
default:
|
||||||
|
return 'w-32 h-32';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Returns normal icon size in px
|
||||||
|
const getIconPixelSize = (size: string | undefined): number => {
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return 34;
|
||||||
|
case 'medium':
|
||||||
|
return 42;
|
||||||
|
case 'large':
|
||||||
|
return 48;
|
||||||
|
default:
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns loading icon size in px
|
||||||
|
const getIconLoadingPixelSize = (size: string | undefined): number => {
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return 24;
|
||||||
|
case 'medium':
|
||||||
|
return 32;
|
||||||
|
case 'large':
|
||||||
|
return 40;
|
||||||
|
default:
|
||||||
|
return 32;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (isEditing) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Simulate loading time (dev purpose)
|
||||||
|
// e.preventDefault();
|
||||||
|
// setTimeout(() => {
|
||||||
|
// setIsLoading(false);
|
||||||
|
// }, 3500); // Small delay to show spinner before navigation
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
||||||
|
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className} transition-all duration-300 ease-in-out`}>
|
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-300 ease-in-out`}>
|
||||||
<a
|
<a
|
||||||
href={isEditing ? undefined : website.url}
|
href={isEditing ? undefined : website.url}
|
||||||
target="_self"
|
target="_self"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleClick}
|
||||||
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-300 ease-in-out hover:scale-105 hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
|
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-300 ease-in-out hover:scale-105 hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
|
||||||
>
|
>
|
||||||
<div className="mb-2 transition-transform duration-300 group-hover:-translate-y-1">
|
{isLoading && (
|
||||||
{LucideIcon ? (
|
<div className="absolute inset-0 flex items-center justify-center mb-6">
|
||||||
<LucideIcon className="h-10 w-10 text-white" />
|
<svg className="animate-spin h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
) : (
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<img src={website.icon} alt={`${website.name} icon`} className="h-10 w-10 object-contain" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
)}
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center transition-all duration-300 ease-in ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
|
||||||
|
<div className={`transition-all duration-300 ease-in ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||||
|
<img src={website.icon} alt={`${website.name} icon`} className={`object-contain w-full h-full`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
|
||||||
|
{website.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-slate-100 font-medium text-base tracking-wide text-center">
|
|
||||||
{website.name}
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
|
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
|
||||||
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><ArrowLeft size={16} /></button>
|
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-left" viewBox="0 0 16 16">
|
||||||
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><Pencil size={16} /></button>
|
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||||
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><ArrowRight size={16} /></button>
|
</svg></button>
|
||||||
|
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
|
</svg></button>
|
||||||
|
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-right" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||||
|
</svg></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
75
components/layout/CategoryGroup.tsx
Normal file
75
components/layout/CategoryGroup.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import WebsiteTile from '../WebsiteTile';
|
||||||
|
import { Category, Website } from '../../types';
|
||||||
|
|
||||||
|
interface CategoryGroupProps {
|
||||||
|
category: Category;
|
||||||
|
isEditing: boolean;
|
||||||
|
setEditingCategory: (category: Category) => void;
|
||||||
|
setIsCategoryModalOpen: (isOpen: boolean) => void;
|
||||||
|
setAddingWebsite: (category: Category) => void;
|
||||||
|
setEditingWebsite: (website: Website) => void;
|
||||||
|
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
||||||
|
getHorizontalAlignmentClass: (alignment: string) => string;
|
||||||
|
config: {
|
||||||
|
horizontalAlignment: string;
|
||||||
|
tileSize?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
|
category,
|
||||||
|
isEditing,
|
||||||
|
setEditingCategory,
|
||||||
|
setIsCategoryModalOpen,
|
||||||
|
setAddingWebsite,
|
||||||
|
setEditingWebsite,
|
||||||
|
handleMoveWebsite,
|
||||||
|
getHorizontalAlignmentClass,
|
||||||
|
config,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div key={category.id} className="w-full">
|
||||||
|
<div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
|
||||||
|
<h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setIsCategoryModalOpen(true);
|
||||||
|
}}
|
||||||
|
className={`ml-2 text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
|
||||||
|
{category.websites.map((website) => (
|
||||||
|
<WebsiteTile
|
||||||
|
key={website.id}
|
||||||
|
website={website}
|
||||||
|
isEditing={isEditing}
|
||||||
|
onEdit={setEditingWebsite}
|
||||||
|
onMove={handleMoveWebsite}
|
||||||
|
tileSize={config.tileSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => setAddingWebsite(category)}
|
||||||
|
className={`text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryGroup;
|
23
components/layout/ConfigurationButton.tsx
Normal file
23
components/layout/ConfigurationButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
|
||||||
|
interface ConfigurationButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
|
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09c.7 0 1.31-.4 1.51-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.51 1.31.61 1.82.33.51-.28 1-.81 1-1.51V3a2 2 0 1 1 4 0v.09c0 .7.49 1.23 1 1.51.51.28 1.31.18 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82c.2.6.81 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.7 0-1.31.4-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigurationButton;
|
25
components/layout/EditButton.tsx
Normal file
25
components/layout/EditButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
|
||||||
|
interface EditButtonProps {
|
||||||
|
isEditing: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 left-4">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||||
|
</svg>
|
||||||
|
{isEditing ? 'Done' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditButton;
|
77
components/layout/Header.tsx
Normal file
77
components/layout/Header.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Clock from '../Clock';
|
||||||
|
import { Config } from '../../types';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getClockSizeClass = (size: string) => {
|
||||||
|
switch (size) {
|
||||||
|
case 'tiny':
|
||||||
|
return 'text-3xl';
|
||||||
|
case 'small':
|
||||||
|
return 'text-4xl';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-5xl';
|
||||||
|
case 'large':
|
||||||
|
return 'text-6xl';
|
||||||
|
default:
|
||||||
|
return 'text-5xl';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitleSizeClass = (size: string) => {
|
||||||
|
switch (size) {
|
||||||
|
case 'tiny':
|
||||||
|
return 'text-4xl';
|
||||||
|
case 'small':
|
||||||
|
return 'text-5xl';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-6xl';
|
||||||
|
case 'large':
|
||||||
|
return 'text-7xl';
|
||||||
|
default:
|
||||||
|
return 'text-6xl';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubtitleSizeClass = (size: string) => {
|
||||||
|
switch (size) {
|
||||||
|
case 'tiny':
|
||||||
|
return 'text-lg';
|
||||||
|
case 'small':
|
||||||
|
return 'text-xl';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-2xl';
|
||||||
|
case 'large':
|
||||||
|
return 'text-3xl';
|
||||||
|
default:
|
||||||
|
return 'text-2xl';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{config.clock.enabled && (
|
||||||
|
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
|
||||||
|
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||||
|
{config.title && (
|
||||||
|
<div className="text-center">
|
||||||
|
<h1
|
||||||
|
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
|
||||||
|
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
|
||||||
|
>
|
||||||
|
{config.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
164
components/utils/StorageLocalManager.ts
Normal file
164
components/utils/StorageLocalManager.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// TypeScript interface for window.chrome
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
chrome?: {
|
||||||
|
storage?: {
|
||||||
|
local?: {
|
||||||
|
set: (items: object, callback?: () => void) => void;
|
||||||
|
get: (keys: string[] | string, callback: (items: { [key: string]: string }) => void) => void;
|
||||||
|
remove: (keys: string | string[], callback?: () => void) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
runtime?: {
|
||||||
|
lastError?: { message: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isChromeStorageLocalAvailable: boolean | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if chrome.storage.local is available and caches the result.
|
||||||
|
*/
|
||||||
|
export function checkChromeStorageLocalAvailable(): boolean {
|
||||||
|
if (isChromeStorageLocalAvailable !== null) return isChromeStorageLocalAvailable;
|
||||||
|
isChromeStorageLocalAvailable =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.chrome !== 'undefined' &&
|
||||||
|
typeof window.chrome.storage !== 'undefined' &&
|
||||||
|
typeof window.chrome.storage.local !== 'undefined';
|
||||||
|
return isChromeStorageLocalAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new wallpaper to chrome.storage.local.
|
||||||
|
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL.
|
||||||
|
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
|
||||||
|
* @param name Wallpaper name (string), used as a fallback.
|
||||||
|
* @param url Wallpaper image URL (string) or base64 data URL.
|
||||||
|
* @returns Promise<string> The name under which the wallpaper was stored.
|
||||||
|
* @throws Error if chrome.storage.local is unavailable or if a name is not provided for a non-fetchable URL.
|
||||||
|
*/
|
||||||
|
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
|
throw new Error('chrome.storage.local is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('data:')) {
|
||||||
|
// This is a base64 encoded image from a file upload.
|
||||||
|
// The name is the file name.
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (window.chrome?.storage?.local) {
|
||||||
|
window.chrome.storage.local.set({ [name]: url }, function () {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
}
|
||||||
|
}).then(() => name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a URL. Let's try to fetch it.
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch image');
|
||||||
|
const imageBlob = await response.blob();
|
||||||
|
const reader = new FileReader();
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(imageBlob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If successful, use the filename from URL as the name.
|
||||||
|
const finalName = url.substring(url.lastIndexOf('/') + 1).replace(/[?#].*$/, '') || name;
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (window.chrome?.storage?.local) {
|
||||||
|
window.chrome.storage.local.set({ [finalName]: base64 }, function () {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
}
|
||||||
|
}).then(() => finalName);
|
||||||
|
} catch (error) {
|
||||||
|
// If fetch fails (e.g., CORS), store the URL directly with the user-provided name.
|
||||||
|
console.warn('Could not fetch wallpaper, storing URL instead. Error:', error);
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("A name for the wallpaper is required when the URL can't be accessed.");
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (window.chrome?.storage?.local) {
|
||||||
|
window.chrome.storage.local.set({ [name]: url }, function () {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
}
|
||||||
|
}).then(() => name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a specific wallpaper from chrome.storage.local by name.
|
||||||
|
* @param name Wallpaper name (string)
|
||||||
|
* @returns Promise<string | null> (base64 string or null)
|
||||||
|
* @throws Error if chrome.storage.local is unavailable
|
||||||
|
*/
|
||||||
|
export async function getWallpaperFromChromeStorageLocal(name: string): Promise<string | null> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
|
throw new Error('chrome.storage.local is not available');
|
||||||
|
}
|
||||||
|
return new Promise<string | null>((resolve, reject) => {
|
||||||
|
if (window.chrome?.storage?.local) {
|
||||||
|
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve(result[name] || null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a wallpaper from chrome.storage.local by name.
|
||||||
|
* @param name Wallpaper name (string)
|
||||||
|
* @returns Promise<void>
|
||||||
|
* @throws Error if chrome.storage.local is unavailable
|
||||||
|
*/
|
||||||
|
export async function removeWallpaperFromChromeStorageLocal(name: string): Promise<void> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
|
throw new Error('chrome.storage.local is not available');
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (window.chrome?.storage?.local) {
|
||||||
|
window.chrome.storage.local.remove(name, function () {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@@ -14,12 +14,16 @@ export const baseWallpapers: Wallpaper[] = [
|
|||||||
name: 'Beach',
|
name: 'Beach',
|
||||||
url: 'https://wallpapershome.com/images/pages/pic_h/615.jpg'
|
url: 'https://wallpapershome.com/images/pages/pic_h/615.jpg'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Dark',
|
||||||
|
url: 'https://i.imgur.com/qHlRO0s.jpeg'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Mountain',
|
name: 'Mountain',
|
||||||
url: 'https://i.imgur.com/yHfOZUd.jpeg'
|
url: 'https://i.imgur.com/yHfOZUd.jpeg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Waves',
|
name: 'Waves',
|
||||||
url: 'waves.jpg',
|
url: 'https://i.imgur.com/E8uxZ7R.png',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
BIN
icon.png
Executable file
BIN
icon.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 763 KiB |
15
index.html
15
index.html
@@ -4,19 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vision Start</title>
|
<title>Vision Start</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"react": "https://esm.sh/react@^19.1.0",
|
|
||||||
"react-dom/": "https://esm.sh/react-dom@^19.1.0/",
|
|
||||||
"react/": "https://esm.sh/react@^19.1.0/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black">
|
<body class="bg-black">
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
15
manifest.json
Normal file
15
manifest.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Vision Startpage",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "A beautiful and customizable startpage for your browser.",
|
||||||
|
"chrome_url_overrides": {
|
||||||
|
"newtab": "index.html"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||||
|
}
|
||||||
|
}
|
1428
package-lock.json
generated
1428
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"lucide-react": "^0.525.0",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
}
|
}
|
||||||
|
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {}, // ← use the new package name
|
||||||
|
autoprefixer: {},
|
||||||
|
}
|
||||||
|
}
|
BIN
public/favicon.ico
Executable file
BIN
public/favicon.ico
Executable file
Binary file not shown.
After Width: | Height: | Size: 763 KiB |
BIN
screenshots/configuration-abstract-red.png
Normal file
BIN
screenshots/configuration-abstract-red.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 744 KiB |
BIN
screenshots/dark-page.png
Normal file
BIN
screenshots/dark-page.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.3 MiB |
BIN
screenshots/editing-abstract-red.png
Normal file
BIN
screenshots/editing-abstract-red.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 MiB |
113
scripts/check_virustotal.sh
Executable file
113
scripts/check_virustotal.sh
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to check a file against VirusTotal API
|
||||||
|
# Requires: curl, jq
|
||||||
|
# Environment variable: virustotal_apikey
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
|
||||||
|
API_KEY="${virustotal_apikey}"
|
||||||
|
BASE_URL="https://www.virustotal.com/vtapi/v2"
|
||||||
|
|
||||||
|
# Check if API key is set
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "Error: virustotal_apikey environment variable is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if file exists
|
||||||
|
if [ ! -f "$FILE_PATH" ]; then
|
||||||
|
echo "Error: File $FILE_PATH not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if required tools are available
|
||||||
|
if ! command -v curl &> /dev/null; then
|
||||||
|
echo "Error: curl is required but not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v jq &> /dev/null; then
|
||||||
|
echo "Error: jq is required but not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uploading $FILE_PATH to VirusTotal for analysis..."
|
||||||
|
|
||||||
|
# Upload file to VirusTotal
|
||||||
|
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||||
|
-F "apikey=$API_KEY" \
|
||||||
|
-F "file=@$FILE_PATH" \
|
||||||
|
"$BASE_URL/file/scan")
|
||||||
|
|
||||||
|
# Extract scan_id from response
|
||||||
|
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.scan_id')
|
||||||
|
|
||||||
|
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
|
||||||
|
echo "Error: Failed to upload file or get scan ID"
|
||||||
|
echo "Response: $UPLOAD_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "File uploaded successfully. Scan ID: $SCAN_ID"
|
||||||
|
echo "Waiting for analysis to complete..."
|
||||||
|
|
||||||
|
# Wait for analysis to complete and get results
|
||||||
|
MAX_ATTEMPTS=30
|
||||||
|
ATTEMPT=0
|
||||||
|
SLEEP_INTERVAL=10
|
||||||
|
|
||||||
|
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||||
|
echo "Checking analysis status (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
|
||||||
|
|
||||||
|
# Get scan report
|
||||||
|
REPORT_RESPONSE=$(curl -s -X POST \
|
||||||
|
-d "apikey=$API_KEY" \
|
||||||
|
-d "resource=$SCAN_ID" \
|
||||||
|
"$BASE_URL/file/report")
|
||||||
|
|
||||||
|
# Check if analysis is complete
|
||||||
|
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.response_code')
|
||||||
|
|
||||||
|
if [ "$RESPONSE_CODE" == "1" ]; then
|
||||||
|
# Analysis complete
|
||||||
|
echo "Analysis completed!"
|
||||||
|
|
||||||
|
# Extract results
|
||||||
|
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.positives')
|
||||||
|
TOTAL=$(echo "$REPORT_RESPONSE" | jq -r '.total')
|
||||||
|
PERMALINK=$(echo "$REPORT_RESPONSE" | jq -r '.permalink')
|
||||||
|
|
||||||
|
echo "Analysis URL: $PERMALINK"
|
||||||
|
echo "Detection ratio: $POSITIVES/$TOTAL"
|
||||||
|
|
||||||
|
# Check if file is safe
|
||||||
|
if [ "$POSITIVES" -eq 0 ]; then
|
||||||
|
echo "✅ File is clean (no threats detected)"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "❌ File contains threats ($POSITIVES detections out of $TOTAL scanners)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [ "$RESPONSE_CODE" == "0" ]; then
|
||||||
|
# File not found or analysis not complete yet
|
||||||
|
echo "Analysis still in progress..."
|
||||||
|
elif [ "$RESPONSE_CODE" == "-2" ]; then
|
||||||
|
# Still queued for analysis
|
||||||
|
echo "File still queued for analysis..."
|
||||||
|
else
|
||||||
|
echo "Unexpected response code: $RESPONSE_CODE"
|
||||||
|
echo "Response: $REPORT_RESPONSE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||||
|
sleep $SLEEP_INTERVAL
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timeout: Analysis did not complete within expected time"
|
||||||
|
exit 1
|
1
scripts/prepare_release.sh
Normal file
1
scripts/prepare_release.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
wget https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json
|
22
tailwind.config.js
Normal file
22
tailwind.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
safelist: [
|
||||||
|
'w-[24px]', 'h-[24px]',
|
||||||
|
'w-[28px]', 'h-[28px]',
|
||||||
|
'w-[32px]', 'h-[32px]',
|
||||||
|
'w-[34px]', 'h-[34px]',
|
||||||
|
'w-[36px]', 'h-[36px]',
|
||||||
|
'w-[40px]', 'h-[40px]',
|
||||||
|
'w-[42px]', 'h-[42px]',
|
||||||
|
'w-[48px]', 'h-[48px]',
|
||||||
|
// add any other sizes you use
|
||||||
|
],
|
||||||
|
}
|
25
types.ts
25
types.ts
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
export interface Website {
|
export interface Website {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,3 +23,27 @@ export interface Wallpaper {
|
|||||||
url?: string;
|
url?: string;
|
||||||
base64?: string;
|
base64?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
title: string;
|
||||||
|
currentWallpapers: string[];
|
||||||
|
wallpaperFrequency: string;
|
||||||
|
wallpaperBlur: number;
|
||||||
|
wallpaperBrightness: number;
|
||||||
|
wallpaperOpacity: number;
|
||||||
|
titleSize: string;
|
||||||
|
alignment: string;
|
||||||
|
horizontalAlignment: string;
|
||||||
|
clock: {
|
||||||
|
enabled: boolean;
|
||||||
|
size: string;
|
||||||
|
font: string;
|
||||||
|
format: string;
|
||||||
|
};
|
||||||
|
serverWidget: {
|
||||||
|
enabled: boolean;
|
||||||
|
pingFrequency: number;
|
||||||
|
servers: Server[];
|
||||||
|
};
|
||||||
|
tileSize?: string;
|
||||||
|
}
|
@@ -1,14 +1,27 @@
|
|||||||
import path from 'path';
|
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
import { defineConfig } from 'vite'
|
||||||
const env = loadEnv(mode, '.', '');
|
import react from '@vitejs/plugin-react'
|
||||||
return {
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
define: { },
|
import { resolve } from 'path'
|
||||||
resolve: {
|
|
||||||
alias: {
|
// https://vitejs.dev/config/
|
||||||
'@': path.resolve(__dirname, '.'),
|
export default defineConfig({
|
||||||
}
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'index.html'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'assets/[name].js',
|
||||||
|
chunkFileNames: 'assets/[name].js',
|
||||||
|
assetFileNames: 'assets/[name].[ext]'
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
});
|
// Ensure CSS is extracted to a separate file
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
// Base path for Chrome extension
|
||||||
|
base: './',
|
||||||
|
|
||||||
|
})
|
Reference in New Issue
Block a user