17 Commits

Author SHA1 Message Date
85b239f540 updating readme
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 6s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m1s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
2026-03-21 13:09:45 -03:00
7efdd17534 refactoring configuration modal 2026-03-21 12:58:21 -03:00
b1957f2c19 adding import/export for configuration
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 46s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m22s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 12s
2026-03-21 12:44:23 -03:00
efc9e5c3dd updating virustotal to v3 endpoints
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release / build (push) Successful in 38s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m15s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / virus-total-check (push) Successful in 49s
Build and Release / release (push) Successful in 5s
Build and Release / Build Vision Start Image (push) Successful in 1m1s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
2026-03-21 00:59:49 -03:00
65c6946e7f adjusting intervals
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release / build (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m2s
Build and Release / virus-total-check (push) Failing after 37s
Build and Release / release (push) Has been skipped
Build and Release / Build Vision Start Image (push) Has been skipped
Build and Release / Deploy Vision Start (production) (push) Has been skipped
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
2026-03-21 00:48:25 -03:00
3129fa6531 Merge branch 'main' of git.ivanch.me:ivanch/vision-start
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 9s
Build and Release / build (push) Successful in 16s
Build and Release / virus-total-check (push) Failing after 1m7s
Build and Release / release (push) Has been skipped
Build and Release / Build Vision Start Image (push) Has been skipped
Build and Release / Deploy Vision Start (production) (push) Has been skipped
2026-03-21 00:38:04 -03:00
82da27cf8d fixing release pipeline (final ;)) 2026-03-21 00:38:00 -03:00
c4dce04d42 migrate to Preact and add animations (#1)
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m1s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
- Replace React 19 with Preact via @preact/preset-vite (zero component changes needed — Vite aliases react → preact/compat at build time)
- Add custom iOS easing curves (ease-ios, ease-spring) via Tailwind @theme
- Update all transitions to use iOS-standard 200ms durations and spring/decel easing
- Add active:scale press feedback on tiles, buttons, and toggles
- Toggle knob now uses spring easing for a satisfying snap

Reviewed-on: #1
Co-authored-by: Jose Henrique <jose.henrique.ivan@gmail.com>
Co-committed-by: Jose Henrique <jose.henrique.ivan@gmail.com>
2026-03-21 03:32:01 +00:00
c2b3356022 trying ubuntu-amd64
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m28s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 8s
2026-03-21 00:25:25 -03:00
d067e0b95c try
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 3m29s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-21 00:16:32 -03:00
aec7a331c6 maybe now
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 1m32s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:49:30 -03:00
0d636ab680 another try
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 1m32s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:46:29 -03:00
69c6c6fe09 maybe fixing pipeline docker
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 2m11s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:37:41 -03:00
fd552c48cd fixing pipelines
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 1m35s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:31:30 -03:00
95b7be5219 fixing both pipelines
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 12s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been cancelled
Build and Release to Staging / Build Vision Start Image (push) Has been cancelled
2026-03-20 23:27:41 -03:00
b8e1468a46 adding dockerfile and pipelines
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 50s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been cancelled
Build and Release to Staging / Build Vision Start Image (push) Has been cancelled
2026-03-20 23:26:18 -03:00
199d92f733 updating .gitignore 2026-03-20 23:14:32 -03:00
14 changed files with 1030 additions and 652 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
.DS_Store
.claude/

View File

@@ -1,14 +1,22 @@
name: Build and Release
name: Build and Release to Staging
on:
push:
branches:
- main
workflow_dispatch:
env:
REGISTRY_HOST: git.ivanch.me
REGISTRY_USERNAME: ivanch
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
IMAGE_TAG: staging
jobs:
build:
name: Build Vision Start
if: gitea.event_name == 'push'
runs-on: ubuntu-latest
runs-on: ubuntu-amd64
steps:
- name: Check out repository code
uses: actions/checkout@v4
@@ -16,3 +24,47 @@ jobs:
run: npm install
- name: Run build
run: npm run build
build_vision_start:
name: Build Vision Start Image
runs-on: ubuntu-amd64
needs: build
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Log in to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
| docker login "${{ env.REGISTRY_HOST }}" \
-u "${{ env.REGISTRY_USERNAME }}" \
--password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Multi-Arch Image
uses: docker/build-push-action@v6
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
deploy_vision_start:
name: Deploy Vision Start (staging)
runs-on: ubuntu-amd64
needs: build_vision_start
steps:
- name: Recreate Container
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
cd ${{ secrets.STAGING_DIR }}
docker compose pull
docker compose up -d --force-recreate

View File

@@ -5,6 +5,12 @@ on:
tags:
- v*
env:
REGISTRY_HOST: git.ivanch.me
REGISTRY_USERNAME: ivanch
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
IMAGE_TAG: latest
jobs:
build:
runs-on: ubuntu-latest
@@ -20,7 +26,7 @@ jobs:
- name: Run build
run: npm run build
- name: Prepare release
run: |
run: |
bash scripts/prepare_release.sh
mv dist vision-start/
mv manifest.json vision-start/
@@ -53,19 +59,17 @@ jobs:
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
# Run the VirusTotal check script and capture output in real-time
set -o pipefail
bash scripts/check_virustotal.sh 2>&1 | tee vt_output.txt
# 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
@@ -82,9 +86,53 @@ jobs:
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
files: vision-start-${{ gitea.ref_name }}.zip
build_vision_start:
name: Build Vision Start Image
runs-on: ubuntu-amd64
needs: [build, virus-total-check]
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Log in to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
| docker login "${{ env.REGISTRY_HOST }}" \
-u "${{ env.REGISTRY_USERNAME }}" \
--password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Multi-Arch Image
uses: docker/build-push-action@v6
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
deploy_vision_start:
name: Deploy Vision Start (production)
runs-on: ubuntu-amd64
needs: build_vision_start
steps:
- name: Recreate Container
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
cd ${{ secrets.PROD_DIR }}
docker compose pull
docker compose up -d --force-recreate

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.claude/
# Editor directories and files
.vscode/*

39
App.tsx
View File

@@ -10,29 +10,7 @@ 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 = {
title: 'Vision Start',
currentWallpapers: ['Abstract'],
wallpaperFrequency: '1d',
wallpaperBlur: 0,
wallpaperBrightness: 100,
wallpaperOpacity: 100,
titleSize: 'medium',
alignment: 'middle',
horizontalAlignment: 'middle',
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
},
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
},
};
import { ConfigurationService } from './components/services/ConfigurationService';
const App: React.FC = () => {
const [categories, setCategories] = useState<Category[]>(() => {
@@ -52,21 +30,10 @@ const App: React.FC = () => {
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [config, setConfig] = useState<Config>(() => {
try {
const storedConfig = localStorage.getItem('config');
if (storedConfig) {
const parsedConfig = JSON.parse(storedConfig);
return { ...defaultConfig, ...parsedConfig };
}
} catch (error) {
console.error('Error parsing config from localStorage', error);
}
return { ...defaultConfig };
});
const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig());
useEffect(() => {
localStorage.setItem('config', JSON.stringify(config));
ConfigurationService.saveConfig(config);
}, [config]);
useEffect(() => {

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN sh scripts/prepare_release.sh
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY manifest.json /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,5 +1,12 @@
# Vision Start
#### A glassmorphism-looking like, modern and customizable startpage built with React.
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
Vision Start
</div>
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
A glassmorphism-looking like, modern and customizable startpage built with React.
</div>
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
## Screenshots
@@ -25,7 +32,7 @@ Vision Start is not yet available on Chrome Web Store, but it can be installed m
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
* **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 :(
* **Settings:** A settings page to configure the startpage, with export/import functionality.
## Backgrounds
@@ -87,4 +94,3 @@ npm run dev
From a technical side:
* Refactor everything :(
* Add small nginx demo (with docker)

View File

@@ -1,71 +1,42 @@
import React, { useState, useRef, useEffect } from 'react';
import ToggleSwitch from './ToggleSwitch';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { Server, Wallpaper } from '../types';
import Dropdown from './Dropdown';
import { Config, Wallpaper } from '../types';
import { baseWallpapers } from './utils/baseWallpapers';
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
import { ConfigurationService } from './services/ConfigurationService';
import GeneralTab from './configuration/GeneralTab';
import ThemeTab from './configuration/ThemeTab';
import ClockTab from './configuration/ClockTab';
import ServerWidgetTab from './configuration/ServerWidgetTab';
interface ConfigurationModalProps {
onClose: () => void;
onSave: (config: any) => void;
currentConfig: any;
onWallpaperChange: (newConfig: Partial<any>) => void;
onSave: (config: Config) => void;
currentConfig: Config;
onWallpaperChange: (newConfig: Partial<Config>) => void;
}
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig, onWallpaperChange }) => {
const [config, setConfig] = useState({
...currentConfig,
titleSize: currentConfig.titleSize || 'medium',
alignment: currentConfig.alignment || 'middle',
tileSize: currentConfig.tileSize || 'medium',
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
wallpaperBlur: currentConfig.wallpaperBlur || 0,
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
...currentConfig.serverWidget,
},
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
...currentConfig.clock,
},
currentWallpapers: Array.isArray(currentConfig.currentWallpapers)
? currentConfig.currentWallpapers.filter((name: string) => typeof name === 'string')
: [],
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
});
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
onClose,
onSave,
currentConfig,
onWallpaperChange,
}) => {
const [config, setConfig] = useState<Config>(currentConfig);
const [activeTab, setActiveTab] = useState('general');
const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
const [isVisible, setIsVisible] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
useEffect(() => {
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
const storedUserWallpapers = localStorage.getItem('userWallpapers');
if (storedUserWallpapers) {
setUserWallpapers(JSON.parse(storedUserWallpapers));
}
setUserWallpapers(ConfigurationService.loadUserWallpapers());
}, []);
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, 10);
const timer = setTimeout(() => setIsVisible(true), 10);
return () => clearTimeout(timer);
}, []);
@@ -77,167 +48,86 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
};
}, []);
const handleClose = () => {
setIsVisible(false);
setTimeout(() => {
onClose();
}, 250);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
const { name, value } = e.target;
if (name === 'currentWallpapers') {
const wallpaperNames = Array.isArray(value) ? value : [value];
setConfig({ ...config, currentWallpapers: wallpaperNames });
} else if (name.startsWith('serverWidget.')) {
const field = name.split('.')[1];
setConfig({
...config,
serverWidget: { ...config.serverWidget, [field]: value },
});
} else if (name.startsWith('clock.')) {
const field = name.split('.')[1];
setConfig({
...config,
clock: { ...config.clock, [field]: value },
});
} else {
setConfig({ ...config, [name]: value });
}
};
useEffect(() => {
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
// Set wallpaperState in localStorage with lastWallpaperChange datetime
localStorage.setItem('wallpaperState', JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: 0,
}));
ConfigurationService.resetWallpaperState();
}, [config.currentWallpapers]);
const handleClockToggleChange = (checked: boolean) => {
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 250);
};
const handleServerWidgetToggleChange = (checked: boolean) => {
setConfig({
...config,
serverWidget: { ...config.serverWidget, enabled: checked },
});
const handleConfigChange = (updates: Partial<Config>) => {
setConfig((prev) => ({ ...prev, ...updates }));
};
const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
const newServer: Server = {
id: Date.now().toString(),
name: newServerName,
address: newServerAddress,
};
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: [...config.serverWidget.servers, newServer],
},
});
setNewServerName('');
setNewServerAddress('');
const handleAddWallpaper = async (name: string, url: string) => {
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
};
const handleRemoveServer = (id: string) => {
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: config.serverWidget.servers.filter((server: Server) => server.id !== id),
},
});
const handleAddWallpaperFile = async (file: File) => {
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
};
const onDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(config.serverWidget.servers);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: items,
},
});
};
const handleAddWallpaper = async () => {
if (newWallpaperUrl.trim() === '') return;
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
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>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 4 * 1024 * 1024) {
alert('File size exceeds 4MB. Please choose a smaller file.');
return;
}
const reader = new FileReader();
reader.onload = async () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
alert('The uploaded image is too large. Please choose a smaller file.');
return;
}
try {
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
const newWallpaper: Wallpaper = { name: finalName };
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
} catch (error) {
alert('Error adding wallpaper. Please try again.');
console.error(error);
}
};
reader.readAsDataURL(file);
}
};
const handleDeleteUserWallpaper = async (wallpaper: Wallpaper) => {
try {
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
const newcurrentWallpapers = config.currentWallpapers.filter((name: string) => name !== wallpaper.name);
const newConfig = { ...config, currentWallpapers: newcurrentWallpapers };
setConfig(newConfig);
onWallpaperChange({ currentWallpapers: newcurrentWallpapers });
await ConfigurationService.deleteWallpaper(wallpaper);
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
} catch (error) {
alert('Error deleting wallpaper. Please try again.');
console.error(error);
}
};
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const { config: importedConfig, userWallpapers: importedWallpapers } =
await ConfigurationService.importConfig(file);
setConfig(importedConfig);
setUserWallpapers(importedWallpapers);
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
onSave(importedConfig);
alert('Configuration imported successfully. The page will reload to apply all data.');
window.location.reload();
} catch (error) {
alert('Could not import configuration. Please use a valid export JSON file.');
console.error(error);
} finally {
event.target.value = '';
}
};
const allWallpapers = [...baseWallpapers, ...userWallpapers];
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'theme', label: 'Theme' },
{ id: 'clock', label: 'Clock' },
{ id: 'serverWidget', label: 'Server Widget' },
];
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
<div
@@ -245,7 +135,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
isVisible ? 'opacity-100' : 'opacity-0'
}`}
onClick={handleClose}
></div>
/>
<div
ref={menuRef}
@@ -257,402 +147,89 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
<div className="flex border-b border-white/10 mb-6">
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'general' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('general')}
>
General
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'theme' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('theme')}
>
Theme
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'clock' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('clock')}
>
Clock
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'serverWidget' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('serverWidget')}
>
Server Widget
</button>
{tabs.map((tab) => (
<button
key={tab.id}
className={`px-4 py-2 text-lg font-semibold ${
activeTab === tab.id
? 'text-cyan-400 border-b-2 border-cyan-400'
: 'text-slate-400'
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'general' && (
<div className="flex flex-col gap-6">
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
<input
type="text"
name="title"
value={config.title}
onChange={handleChange}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
<Dropdown
name="titleSize"
value={config.titleSize}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
<Dropdown
name="alignment"
value={config.alignment}
onChange={handleChange}
options={[
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
<Dropdown
name="tileSize"
value={config.tileSize}
onChange={handleChange}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
<Dropdown
name="horizontalAlignment"
value={config.horizontalAlignment}
onChange={handleChange}
options={[
{ value: 'left', label: 'Left' },
{ value: 'middle', label: 'Middle' },
{ value: 'right', label: 'Right' },
]}
/>
</div>
</div>
<GeneralTab config={config} onChange={handleConfigChange} />
)}
{activeTab === 'theme' && (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown
name="currentWallpapers"
value={config.currentWallpapers}
onChange={handleChange}
multiple
options={allWallpapers.map(w => ({
value: w.name,
label: w.name
}))}
/>
</div>
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={handleChange}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
/>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperBlur"
min="0"
max="50"
value={config.wallpaperBlur}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperBrightness"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperOpacity"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperOpacity}%</span>
</div>
</div>
{chromeStorageAvailable && (
<>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
{userWallpapers.map((wallpaper) => (
<div key={wallpaper.name} className="flex items-center justify-between bg-white/10 p-2 rounded-lg">
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => handleDeleteUserWallpaper(wallpaper)}
className="text-red-500 hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
))}
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name (optional for URLs)"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddWallpaper}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
</svg>
<p className="mb-2 text-sm text-gray-400"><span className="font-semibold">Click to upload</span> or drag and drop</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
</label>
</div>
</div>
</div>
</>
)}
</div>
<ThemeTab
config={config}
onChange={handleConfigChange}
userWallpapers={userWallpapers}
allWallpapers={allWallpapers}
chromeStorageAvailable={chromeStorageAvailable}
onAddWallpaper={handleAddWallpaper}
onAddWallpaperFile={handleAddWallpaperFile}
onDeleteWallpaper={handleDeleteWallpaper}
/>
)}
{activeTab === 'clock' && (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
<ToggleSwitch
checked={config.clock.enabled}
onChange={handleClockToggleChange}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
<Dropdown
name="clock.size"
value={config.clock.size}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
<Dropdown
name="clock.font"
value={config.clock.font}
onChange={handleChange}
options={[
{ value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
<Dropdown
name="clock.format"
value={config.clock.format}
onChange={handleChange}
options={[
{ value: 'h:mm A', label: 'AM/PM' },
{ value: 'HH:mm', label: '24:00' },
]}
/>
</div>
</div>
<ClockTab config={config} onChange={handleConfigChange} />
)}
{activeTab === 'serverWidget' && (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
<ToggleSwitch
checked={config.serverWidget.enabled}
onChange={handleServerWidgetToggleChange}
/>
</div>
{config.serverWidget.enabled && (
<>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
name="serverWidget.pingFrequency"
min="5"
max="60"
value={config.serverWidget.pingFrequency}
onChange={handleChange}
className="w-48"
/>
<span>{config.serverWidget.pingFrequency}s</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="servers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex flex-col gap-2">
{config.serverWidget.servers.map((server: Server, index: number) => (
<Draggable key={server.id} draggableId={server.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
>
<div>
<p className="font-semibold">{server.name}</p>
<p className="text-sm text-slate-400">{server.address}</p>
</div>
<button
onClick={() => handleRemoveServer(server.id)}
className="text-red-500 hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex gap-2 mt-2">
<input
type="text"
placeholder="Server Name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="HTTP Address"
value={newServerAddress}
onChange={(e) => setNewServerAddress(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddServer}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
</div>
</>
)}
</div>
<ServerWidgetTab config={config} onChange={handleConfigChange} />
)}
</div>
<div className="p-8 border-t border-white/10">
<div className="flex justify-end gap-4">
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
Save & Close
</button>
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios">
Cancel
</button>
<div className="flex items-center justify-between gap-4">
<div className="flex gap-2">
<button
onClick={() => ConfigurationService.exportConfig()}
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
>
Export
</button>
<button
onClick={() => importInputRef.current?.click()}
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
>
Import
</button>
<input
ref={importInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={handleImportConfig}
/>
</div>
<div className="flex justify-end gap-4">
<button
onClick={() => {
isSaving.current = true;
onSave(config);
}}
className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
>
Save & Close
</button>
<button
onClick={handleClose}
className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default ConfigurationModal;
export default ConfigurationModal;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import Dropdown from '../Dropdown';
import ToggleSwitch from '../ToggleSwitch';
import { Config } from '../../types';
interface ClockTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
const updateClock = (updates: Partial<Config['clock']>) => {
onChange({ clock: { ...config.clock, ...updates } });
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
<ToggleSwitch
checked={config.clock.enabled}
onChange={(checked) => updateClock({ enabled: checked })}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
<Dropdown
name="clock.size"
value={config.clock.size}
onChange={(e) => updateClock({ size: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
<Dropdown
name="clock.font"
value={config.clock.font}
onChange={(e) => updateClock({ font: e.target.value as string })}
options={[
{ value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
<Dropdown
name="clock.format"
value={config.clock.format}
onChange={(e) => updateClock({ format: e.target.value as string })}
options={[
{ value: 'h:mm A', label: 'AM/PM' },
{ value: 'HH:mm', label: '24:00' },
]}
/>
</div>
</div>
);
};
export default ClockTab;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import Dropdown from '../Dropdown';
import { Config } from '../../types';
interface GeneralTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
return (
<div className="flex flex-col gap-6">
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
<input
type="text"
value={config.title}
onChange={(e) => onChange({ title: e.target.value })}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
<Dropdown
name="titleSize"
value={config.titleSize}
onChange={(e) => onChange({ titleSize: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
<Dropdown
name="alignment"
value={config.alignment}
onChange={(e) => onChange({ alignment: e.target.value as string })}
options={[
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
<Dropdown
name="tileSize"
value={config.tileSize || 'medium'}
onChange={(e) => onChange({ tileSize: e.target.value as string })}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
<Dropdown
name="horizontalAlignment"
value={config.horizontalAlignment}
onChange={(e) => onChange({ horizontalAlignment: e.target.value as string })}
options={[
{ value: 'left', label: 'Left' },
{ value: 'middle', label: 'Middle' },
{ value: 'right', label: 'Right' },
]}
/>
</div>
</div>
);
};
export default GeneralTab;

View File

@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import ToggleSwitch from '../ToggleSwitch';
import { Config, Server } from '../../types';
interface ServerWidgetTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
const updateServerWidget = (updates: Partial<Config['serverWidget']>) => {
onChange({ serverWidget: { ...config.serverWidget, ...updates } });
};
const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
const newServer: Server = {
id: Date.now().toString(),
name: newServerName,
address: newServerAddress,
};
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
setNewServerName('');
setNewServerAddress('');
};
const handleRemoveServer = (id: string) => {
updateServerWidget({
servers: config.serverWidget.servers.filter((s) => s.id !== id),
});
};
const onDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(config.serverWidget.servers);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
updateServerWidget({ servers: items });
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
<ToggleSwitch
checked={config.serverWidget.enabled}
onChange={(checked) => updateServerWidget({ enabled: checked })}
/>
</div>
{config.serverWidget.enabled && (
<>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="60"
value={config.serverWidget.pingFrequency}
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
className="w-48"
/>
<span>{config.serverWidget.pingFrequency}s</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="servers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex flex-col gap-2"
>
{config.serverWidget.servers.map((server: Server, index: number) => (
<Draggable key={server.id} draggableId={server.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
>
<div>
<p className="font-semibold">{server.name}</p>
<p className="text-sm text-slate-400">{server.address}</p>
</div>
<button
onClick={() => handleRemoveServer(server.id)}
className="text-red-500 hover:text-red-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
</button>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex gap-2 mt-2">
<input
type="text"
placeholder="Server Name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="HTTP Address"
value={newServerAddress}
onChange={(e) => setNewServerAddress(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddServer}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
</div>
</>
)}
</div>
);
};
export default ServerWidgetTab;

View File

@@ -0,0 +1,228 @@
import React, { useRef, useState } from 'react';
import Dropdown from '../Dropdown';
import { Config, Wallpaper } from '../../types';
interface ThemeTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
userWallpapers: Wallpaper[];
allWallpapers: Wallpaper[];
chromeStorageAvailable: boolean;
onAddWallpaper: (name: string, url: string) => Promise<void>;
onAddWallpaperFile: (file: File) => Promise<void>;
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
}
const ThemeTab: React.FC<ThemeTabProps> = ({
config,
onChange,
userWallpapers,
allWallpapers,
chromeStorageAvailable,
onAddWallpaper,
onAddWallpaperFile,
onDeleteWallpaper,
}) => {
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAddWallpaper = async () => {
if (newWallpaperUrl.trim() === '') return;
try {
await onAddWallpaper(newWallpaperName, newWallpaperUrl);
setNewWallpaperName('');
setNewWallpaperUrl('');
} catch (error) {
alert('Error adding wallpaper. Please check the URL and try again.');
console.error(error);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await onAddWallpaperFile(file);
} catch (error: any) {
alert(error?.message || 'Error adding wallpaper. Please try again.');
console.error(error);
}
e.target.value = '';
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown
name="currentWallpapers"
value={config.currentWallpapers}
onChange={(e) => onChange({ currentWallpapers: e.target.value as string[] })}
multiple
options={allWallpapers.map((w) => ({ value: w.name, label: w.name }))}
/>
</div>
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
/>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
value={config.wallpaperBlur}
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
className="w-48"
/>
<span>{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
className="w-48"
/>
<span>{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
className="w-48"
/>
<span>{config.wallpaperOpacity}%</span>
</div>
</div>
{chromeStorageAvailable && (
<>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
{userWallpapers.map((wallpaper) => (
<div
key={wallpaper.name}
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
>
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => onDeleteWallpaper(wallpaper)}
className="text-red-500 hover:text-red-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
</button>
</div>
))}
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name (optional for URLs)"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddWallpaper}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg
className="w-8 h-8 mb-4 text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 16"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
/>
</svg>
<p className="mb-2 text-sm text-gray-400">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input
id="file-upload"
type="file"
className="hidden"
onChange={handleFileUpload}
ref={fileInputRef}
/>
</label>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default ThemeTab;

View File

@@ -0,0 +1,181 @@
import { Config, Wallpaper } from '../../types';
import {
addWallpaperToChromeStorageLocal,
removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager';
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
export const DEFAULT_CONFIG: Config = {
title: 'Vision Start',
currentWallpapers: ['Abstract'],
wallpaperFrequency: '1d',
wallpaperBlur: 0,
wallpaperBrightness: 100,
wallpaperOpacity: 100,
titleSize: 'medium',
alignment: 'middle',
horizontalAlignment: 'middle',
tileSize: 'medium',
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
},
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
},
};
const safeParse = (value: string | null): unknown => {
if (value === null) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
};
const toStorageString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value);
export const ConfigurationService = {
loadConfig(): Config {
try {
const stored = localStorage.getItem('config');
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_CONFIG, ...parsed };
}
} catch (error) {
console.error('Error parsing config from localStorage', error);
}
return { ...DEFAULT_CONFIG };
},
saveConfig(config: Config): void {
localStorage.setItem('config', JSON.stringify(config));
},
loadUserWallpapers(): Wallpaper[] {
try {
const stored = localStorage.getItem('userWallpapers');
if (stored) return JSON.parse(stored);
} catch {
// ignore
}
return [];
},
saveUserWallpapers(wallpapers: Wallpaper[]): void {
localStorage.setItem('userWallpapers', JSON.stringify(wallpapers));
},
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
const finalName = await addWallpaperToChromeStorageLocal(name, url);
return { name: finalName };
},
async addWallpaperFile(file: File): Promise<Wallpaper> {
return new Promise((resolve, reject) => {
if (file.size > 4 * 1024 * 1024) {
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
return;
}
const reader = new FileReader();
reader.onload = async () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
return;
}
try {
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
resolve({ name: finalName });
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
},
exportConfig(): void {
const exportPayload = {
version: 1,
exportedAt: new Date().toISOString(),
requiredLocalStorageKeys: [...REQUIRED_LOCAL_STORAGE_KEYS],
localStorage: REQUIRED_LOCAL_STORAGE_KEYS.reduce(
(acc, key) => {
acc[key] = safeParse(localStorage.getItem(key));
return acc;
},
{} as Record<RequiredLocalStorageKey, unknown>,
),
};
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `vision-start-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
const fileContent = await file.text();
const parsed = JSON.parse(fileContent);
const localStorageData =
parsed?.localStorage && typeof parsed.localStorage === 'object'
? parsed.localStorage
: parsed;
if (!localStorageData || typeof localStorageData !== 'object') {
throw new Error('Invalid import file format.');
}
let importedAny = false;
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
const rawValue = (localStorageData as Record<string, unknown>)[key];
localStorage.setItem(key, toStorageString(rawValue));
importedAny = true;
}
});
if (!importedAny) {
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
}
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
.userWallpapers as Wallpaper[];
return {
config: importedConfig || { ...DEFAULT_CONFIG },
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
};
},
resetWallpaperState(): void {
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: 0,
}),
);
},
};

View File

@@ -9,7 +9,7 @@ set -e
# Configuration
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
API_KEY="${virustotal_apikey}"
BASE_URL="https://www.virustotal.com/vtapi/v2"
BASE_URL="https://www.virustotal.com/api/v3"
# Check if API key is set
if [ -z "$API_KEY" ]; then
@@ -38,12 +38,12 @@ echo "Uploading $FILE_PATH to VirusTotal for analysis..."
# Upload file to VirusTotal
UPLOAD_RESPONSE=$(curl -s -X POST \
-F "apikey=$API_KEY" \
-H "x-apikey: $API_KEY" \
-F "file=@$FILE_PATH" \
"$BASE_URL/file/scan")
"$BASE_URL/files")
# Extract scan_id from response
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.scan_id')
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.id')
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
echo "Error: Failed to upload file or get scan ID"
@@ -55,54 +55,54 @@ 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
MAX_ATTEMPTS=60
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")
REPORT_RESPONSE=$(curl -s -X GET \
-H "x-apikey: $API_KEY" \
"$BASE_URL/analyses/$SCAN_ID")
# Check if analysis is complete
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.response_code')
if [ "$RESPONSE_CODE" == "1" ]; then
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.status')
if [ "$RESPONSE_CODE" == "completed" ]; 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')
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.malicious')
SUSPICIOUS=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.suspicious')
# The v3 analyses object has no 'total' field — compute it by summing all stat categories
TOTAL=$(echo "$REPORT_RESPONSE" | jq '[.data.attributes.stats | to_entries[].value] | add')
ANALYSIS_ID=$(echo "$REPORT_RESPONSE" | jq -r '.data.id')
PERMALINK="https://www.virustotal.com/gui/file-analysis/${ANALYSIS_ID}"
echo "Analysis URL: $PERMALINK"
echo "Detection ratio: $POSITIVES/$TOTAL"
# Check if file is safe
if [ "$POSITIVES" -eq 0 ]; then
if [ "$POSITIVES" -eq 0 ] && [ "$SUSPICIOUS" -eq 0 ]; then
echo "✅ File is clean (no threats detected)"
exit 0
else
echo "❌ File contains threats ($POSITIVES detections out of $TOTAL scanners)"
echo "❌ File flagged: $POSITIVES malicious, $SUSPICIOUS suspicious (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
elif [ "$RESPONSE_CODE" == "queued" ]; then
echo "File still queued for analysis..."
elif [ "$RESPONSE_CODE" == "in-progress" ]; then
echo "Analysis still in progress..."
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