4 Commits
v0.1.2 ... main

Author SHA1 Message Date
c60cb24dd4 updating readme
All checks were successful
Build and Release / build (push) Successful in 54s
2025-08-01 23:26:29 -03:00
2f3949c2e3 changes to the overall structure + improving wallpaper handling 2025-08-01 23:23:50 -03:00
9b818b05f9 I have to use some actions from v3 apparently
All checks were successful
Build and Release / build (push) Successful in 26s
Build and Release / virus-total-check (push) Successful in 51s
Build and Release / release (push) Successful in 5s
2025-07-30 18:14:21 -03:00
008d2321e5 adding virus total check
Some checks failed
Build and Release / build (push) Failing after 26s
Build and Release / virus-total-check (push) Has been skipped
Build and Release / release (push) Has been skipped
2025-07-30 18:08:27 -03:00
19 changed files with 630 additions and 218 deletions

View File

@@ -6,13 +6,15 @@ on:
- v*
jobs:
release:
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 zip
run: sudo apt-get install zip -y
- name: Setup required tools
run: sudo apt-get install zip jq curl -y
- name: Install JS dependencies
run: npm install
- name: Run build
@@ -24,9 +26,65 @@ jobs:
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

88
App.tsx
View File

@@ -1,21 +1,20 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import ConfigurationModal from './components/ConfigurationModal';
import ServerWidget from './components/ServerWidget';
import { DEFAULT_CATEGORIES } from './constants';
import { Category, Website, Wallpaper, Config } from './types';
import { Category, Website, Config } from './types';
import WebsiteEditModal from './components/WebsiteEditModal';
import CategoryEditModal from './components/CategoryEditModal';
import Header from './components/layout/Header';
import EditButton from './components/layout/EditButton';
import ConfigurationButton from './components/layout/ConfigurationButton';
import CategoryGroup from './components/layout/CategoryGroup';
import { baseWallpapers } from './components/utils/baseWallpapers';
import Wallpaper from './components/Wallpaper';
const defaultConfig: Config = {
title: 'Vision Start',
subtitle: 'Your personal portal to the web.',
backgroundUrls: ['https://i.imgur.com/C6ynAtX.jpeg'],
currentWallpapers: ['Abstract'],
wallpaperFrequency: '1d',
wallpaperBlur: 0,
wallpaperBrightness: 100,
@@ -60,9 +59,6 @@ const App: React.FC = () => {
const storedConfig = localStorage.getItem('config');
if (storedConfig) {
const parsedConfig = JSON.parse(storedConfig);
if (!parsedConfig.backgroundUrls) {
parsedConfig.backgroundUrls = [parsedConfig.backgroundUrl].filter(Boolean);
}
return { ...defaultConfig, ...parsedConfig };
}
} catch (error) {
@@ -70,62 +66,12 @@ const App: React.FC = () => {
}
return { ...defaultConfig };
});
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>(() => {
const storedUserWallpapers = localStorage.getItem('userWallpapers');
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
});
const [currentWallpaper, setCurrentWallpaper] = useState<string>('');
const allWallpapers = [...baseWallpapers, ...userWallpapers];
useEffect(() => {
const getFrequencyInMs = (frequency: string) => {
const value = parseInt(frequency.slice(0, -1));
const unit = frequency.slice(-1);
if (unit === 'h') return value * 60 * 60 * 1000;
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
return 24 * 60 * 60 * 1000; // Default to 1 day
};
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const lastChanged = wallpaperState.lastChanged ? new Date(wallpaperState.lastChanged).getTime() : 0;
const frequency = getFrequencyInMs(config.wallpaperFrequency);
const updateWallpaper = () => {
const availableWallpapers = allWallpapers.filter(w => config.backgroundUrls.includes(w.url || w.base64));
if (availableWallpapers.length > 0) {
const currentWallpaperFromState = allWallpapers.find(w => w.name === wallpaperState.current);
const currentIndex = currentWallpaperFromState ? availableWallpapers.findIndex(w => w.name === currentWallpaperFromState.name) : -1;
const nextIndex = (currentIndex + 1) % availableWallpapers.length;
const newWallpaper = availableWallpapers[nextIndex];
const newWallpaperUrl = newWallpaper.url || newWallpaper.base64;
setCurrentWallpaper(newWallpaperUrl || '');
localStorage.setItem('wallpaperState', JSON.stringify({ current: newWallpaper.name, lastChanged: new Date().toISOString() }));
} else {
setCurrentWallpaper('');
localStorage.removeItem('wallpaperState');
}
};
const currentWallpaperDetails = allWallpapers.find(w => w.name === wallpaperState.current);
const isCurrentWallpaperValid = currentWallpaperDetails && config.backgroundUrls.includes(currentWallpaperDetails.url || currentWallpaperDetails.base64 || '');
if (!isCurrentWallpaperValid || Date.now() - lastChanged > frequency) {
updateWallpaper();
} else if (currentWallpaperDetails) {
setCurrentWallpaper(currentWallpaperDetails.url || currentWallpaperDetails.base64 || '');
} else {
// Fallback for when there's no valid wallpaper state
updateWallpaper();
}
}, [config.backgroundUrls, config.wallpaperFrequency, allWallpapers]);
useEffect(() => {
localStorage.setItem('categories', JSON.stringify(categories));
localStorage.setItem('config', JSON.stringify(config));
}, [categories, config]);
}, [config]);
const handleSaveConfig = (newConfig: any) => {
const handleSaveConfig = (newConfig: Config) => {
setConfig(newConfig);
setIsConfigModalOpen(false);
};
@@ -201,7 +147,6 @@ const App: React.FC = () => {
};
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
const categoryIndex = categories.findIndex(c => c.id === website.categoryId);
if (categoryIndex === -1) return;
const category = categories[categoryIndex];
@@ -259,14 +204,13 @@ const App: React.FC = () => {
<main
className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
>
<div
className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
style={{
backgroundImage: `url('${currentWallpaper}')`,
filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
opacity: `${config.wallpaperOpacity}%`,
}}
></div>
<Wallpaper
wallpaperNames={config.currentWallpapers}
blur={config.wallpaperBlur}
brightness={config.wallpaperBrightness}
opacity={config.wallpaperOpacity}
wallpaperFrequency={config.wallpaperFrequency}
/>
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
@@ -305,11 +249,7 @@ const App: React.FC = () => {
)}
</div>
{config.serverWidget.enabled && (
<div className="absolute bottom-4 right-4">
<ServerWidget config={config} />
</div>
)}
{config.serverWidget.enabled && <ServerWidget config={config} />}
{(editingWebsite || addingWebsite) && (
<WebsiteEditModal

View File

@@ -7,16 +7,16 @@
![Editing page with Abstract Red background](screenshots/editing-abstract-red.png)
![Configuration Tab opened](screenshots/configuration-abstract-red.png)
## Backgrounds
## Installing
It comes with a selection of some nice pre-defined backgrounds. You can also upload up to one image to it.
* **Abstract**
* **Abstract Red**
* **Beach**
* **Dark**
* **Mountain**
* **Waves**
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.
## Features
@@ -27,6 +27,12 @@ It comes with a selection of some nice pre-defined backgrounds. You can also upl
* **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
@@ -49,10 +55,10 @@ npm run dev
* [x] Multiple Wallpapers
* [x] Remake icons
* [] Increase offline compatibility (might not be possible)
* 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
* [/] 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

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { Category } from '../types';
interface CategoryEditModalProps {
@@ -12,10 +12,6 @@ interface CategoryEditModalProps {
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(category ? category.name : '');
const handleSave = () => {
onSave(name);
};
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();

View File

@@ -5,6 +5,7 @@ import { Server, Wallpaper } from '../types';
import Dropdown from './Dropdown';
import { baseWallpapers } from './utils/baseWallpapers';
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
interface ConfigurationModalProps {
onClose: () => void;
@@ -37,7 +38,9 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
format: 'h:mm A',
...currentConfig.clock,
},
backgroundUrls: currentConfig.backgroundUrls || [],
currentWallpapers: Array.isArray(currentConfig.currentWallpapers)
? currentConfig.currentWallpapers.filter((name: string) => typeof name === 'string')
: [],
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
});
const [activeTab, setActiveTab] = useState('general');
@@ -46,12 +49,14 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
const storedUserWallpapers = localStorage.getItem('userWallpapers');
if (storedUserWallpapers) {
setUserWallpapers(JSON.parse(storedUserWallpapers));
@@ -59,7 +64,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
}, []);
useEffect(() => {
// A small timeout to allow the component to mount before starting the transition
const timer = setTimeout(() => {
setIsVisible(true);
}, 10);
@@ -69,7 +73,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
useEffect(() => {
return () => {
if (!isSaving.current) {
onWallpaperChange({ backgroundUrls: currentConfig.backgroundUrls });
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
}
};
}, []);
@@ -78,12 +82,15 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
setIsVisible(false);
setTimeout(() => {
onClose();
}, 300); // This duration should match the transition duration
}, 300);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
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];
setConfig({
...config,
@@ -101,8 +108,13 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
};
useEffect(() => {
onWallpaperChange({ backgroundUrls: config.backgroundUrls });
}, [config.backgroundUrls]);
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) => {
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
@@ -162,21 +174,21 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
});
};
const handleAddWallpaper = () => {
if (newWallpaperName.trim() === '' || newWallpaperUrl.trim() === '') return;
const newWallpaper: Wallpaper = {
name: newWallpaperName,
url: newWallpaperUrl,
};
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
setNewWallpaperName('');
setNewWallpaperUrl('');
const handleAddWallpaper = async () => {
if (newWallpaperUrl.trim() === '') return;
try {
const finalName = await addWallpaperToChromeStorageLocal(newWallpaperName, newWallpaperUrl);
const newWallpaper: Wallpaper = { name: finalName };
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
setNewWallpaperName('');
setNewWallpaperUrl('');
} catch (error) {
alert('Error adding wallpaper. Please check the URL and try again.');
console.error(error);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -186,39 +198,43 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
alert('File size exceeds 4MB. Please choose a smaller file.');
return;
}
const reader = new FileReader();
reader.onload = () => {
reader.onload = async () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
alert('The uploaded image is too large. Please choose a smaller file.');
return;
}
const updatedUserWallpapers = userWallpapers.filter(w => !w.base64);
const newWallpaper: Wallpaper = {
name: file.name,
base64,
};
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, base64] });
try {
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
const newWallpaper: Wallpaper = { name: finalName };
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
} catch (error) {
alert('Error adding wallpaper. Please try again.');
console.error(error);
}
};
reader.readAsDataURL(file);
}
};
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
const wallpaperIdentifier = wallpaper.url || wallpaper.base64;
const updatedUserWallpapers = userWallpapers.filter(w => (w.url || w.base64) !== wallpaperIdentifier);
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
const newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
const newConfig = { ...config, backgroundUrls: newBackgroundUrls };
setConfig(newConfig);
onWallpaperChange({ backgroundUrls: newBackgroundUrls });
const handleDeleteUserWallpaper = async (wallpaper: Wallpaper) => {
try {
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
const newcurrentWallpapers = config.currentWallpapers.filter((name: string) => name !== wallpaper.name);
const newConfig = { ...config, currentWallpapers: newcurrentWallpapers };
setConfig(newConfig);
onWallpaperChange({ currentWallpapers: newcurrentWallpapers });
} catch (error) {
alert('Error deleting wallpaper. Please try again.');
console.error(error);
}
};
const allWallpapers = [...baseWallpapers, ...userWallpapers];
@@ -365,35 +381,17 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown
name="backgroundUrls"
value={config.backgroundUrls}
name="currentWallpapers"
value={config.currentWallpapers}
onChange={handleChange}
multiple
options={allWallpapers.map(w => ({
value: w.url || w.base64 || '',
label: (
<div className="flex items-center justify-between w-full">
<span>{w.name}</span>
{!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteWallpaper(w);
}}
className="text-red-500 hover:text-red-400 ml-4 p-1 rounded-full flex items-center justify-center"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
)}
</div>
)
value: w.name,
label: w.name
}))}
/>
</div>
{Array.isArray(config.backgroundUrls) && config.backgroundUrls.length > 1 && (
{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
@@ -456,48 +454,71 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<span>{config.wallpaperOpacity}%</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddWallpaper}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
{chromeStorageAvailable && (
<>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
{userWallpapers.map((wallpaper) => (
<div key={wallpaper.name} className="flex items-center justify-between bg-white/10 p-2 rounded-lg">
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => handleDeleteUserWallpaper(wallpaper)}
className="text-red-500 hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
))}
</div>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
</svg>
<p className="mb-2 text-sm text-gray-400"><span className="font-semibold">Click to upload</span> or drag and drop</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name (optional for URLs)"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddWallpaper}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
</label>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
</svg>
<p className="mb-2 text-sm text-gray-400"><span className="font-semibold">Click to upload</span> or drag and drop</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
</label>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Server } from '../types';
import ping from './utils/jsping.js';

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;

106
components/Wallpaper.tsx Normal file
View 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;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService';

View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { Website } from '../types';
interface WebsiteTileProps {
website: Website;
isEditing: boolean;
@@ -28,9 +27,9 @@ const getTileSizeClass = (size: string | undefined) => {
const getIconPixelSize = (size: string | undefined): number => {
switch (size) {
case 'small':
return 32;
return 34;
case 'medium':
return 40;
return 42;
case 'large':
return 48;
default:
@@ -92,7 +91,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
)}
<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" />
<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}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import WebsiteTile from '../WebsiteTile';
import { Category, Website } from '../../types';

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface ConfigurationButtonProps {
onClick: () => void;

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface EditButtonProps {
isEditing: boolean;

View File

@@ -1,4 +1,3 @@
import React from 'react';
import Clock from '../Clock';
import { Config } from '../../types';

View 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'));
}
});
}

113
scripts/check_virustotal.sh Executable file
View 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

View File

@@ -8,4 +8,15 @@ export default {
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
],
}

View File

@@ -27,7 +27,7 @@ export interface Wallpaper {
export interface Config {
title: string;
subtitle: string;
backgroundUrls: string[];
currentWallpapers: string[];
wallpaperFrequency: string;
wallpaperBlur: number;
wallpaperBrightness: number;

BIN
waves.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB