20 Commits

Author SHA1 Message Date
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
d805531369 changing lf 2026-03-20 23:11:52 -03:00
8cc17a5a17 tidy repo
All checks were successful
Build and Release / build (push) Successful in 1m1s
2025-09-20 20:42:46 -03:00
d4c1884471 fixes
All checks were successful
Build and Release / build (push) Successful in 15s
Build and Release / virus-total-check (push) Successful in 37s
Build and Release / release (push) Successful in 7s
2025-08-26 22:11:42 -03:00
377be6e8f6 removing subtitle 2025-08-26 22:00:21 -03:00
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
35 changed files with 1174 additions and 475 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
@@ -53,8 +59,9 @@ 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")
@@ -64,9 +71,6 @@ jobs:
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]
@@ -88,3 +92,47 @@ jobs:
name: ${{ gitea.ref_name }}
tag_name: ${{ gitea.ref_name }}
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 Executable file → Normal file
View File

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

106
App.tsx Executable file → Normal file
View File

@@ -1,27 +1,24 @@
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,
wallpaperOpacity: 100,
titleSize: 'medium',
subtitleSize: 'medium',
alignment: 'middle',
horizontalAlignment: 'middle',
clock: {
@@ -60,9 +57,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 +64,20 @@ 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) => {
useEffect(() => {
try {
localStorage.setItem('categories', JSON.stringify(categories));
} catch (error) {
console.error('Error saving categories to localStorage', error);
}
}, [categories]);
const handleSaveConfig = (newConfig: Config) => {
setConfig(newConfig);
setIsConfigModalOpen(false);
};
@@ -136,10 +88,11 @@ const App: React.FC = () => {
const handleSaveWebsite = (website: Partial<Website>) => {
if (editingWebsite) {
const idToUpdate = website.id ?? editingWebsite.id;
const newCategories = categories.map(category => ({
...category,
websites: category.websites.map(w =>
w.id === website.id ? { ...w, ...website } : w
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate } : w
),
}));
setCategories(newCategories);
@@ -201,7 +154,7 @@ const App: React.FC = () => {
};
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
const categoryIndex = categories.findIndex(c => c.id === website.categoryId);
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
if (categoryIndex === -1) return;
const category = categories[categoryIndex];
@@ -259,14 +212,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)} />
@@ -288,13 +240,13 @@ const App: React.FC = () => {
/>
))}
{isEditing && (
<div className={`flex justify-center transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
<div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
<button
onClick={() => {
setEditingCategory(null);
setIsCategoryModalOpen(true);
}}
className="text-white/50 hover:text-white transition-colors"
className="text-white/50 hover:text-white active:scale-90 transition-all duration-150 ease-ios"
>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
@@ -305,11 +257,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

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;"]

27
README.md Executable file → Normal file
View File

@@ -18,17 +18,6 @@ Vision Start is not yet available on Chrome Web Store, but it can be installed m
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.
## Backgrounds
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**
## Features
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
@@ -38,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
@@ -60,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
@@ -87,7 +82,7 @@ npm run dev
* Dynamic Wallpaper-Based Theming
* Automatically adapt the UI's accent color to match the current wallpaper
* Minimal feel toggle
* Disable title & subtitle and search widget
* Disable title and search widget
* Tiles become small stylish lines
From a technical side:

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();
@@ -44,7 +40,7 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
)}
</div>
<div className="flex justify-end gap-4">
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
<button onClick={() => onSave(name)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save
</button>
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">

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;
@@ -17,7 +18,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
const [config, setConfig] = useState({
...currentConfig,
titleSize: currentConfig.titleSize || 'medium',
subtitleSize: currentConfig.subtitleSize || 'medium',
alignment: currentConfig.alignment || 'middle',
tileSize: currentConfig.tileSize || 'medium',
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
@@ -37,7 +37,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 +48,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 +63,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 +72,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
useEffect(() => {
return () => {
if (!isSaving.current) {
onWallpaperChange({ backgroundUrls: currentConfig.backgroundUrls });
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
}
};
}, []);
@@ -78,12 +81,15 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
setIsVisible(false);
setTimeout(() => {
onClose();
}, 300); // This duration should match the transition duration
}, 250);
};
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 +107,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 +173,21 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
});
};
const handleAddWallpaper = () => {
if (newWallpaperName.trim() === '' || newWallpaperUrl.trim() === '') return;
const newWallpaper: Wallpaper = {
name: newWallpaperName,
url: newWallpaperUrl,
};
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, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
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 +197,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);
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 newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
const newConfig = { ...config, backgroundUrls: newBackgroundUrls };
const newcurrentWallpapers = config.currentWallpapers.filter((name: string) => name !== wallpaper.name);
const newConfig = { ...config, currentWallpapers: newcurrentWallpapers };
setConfig(newConfig);
onWallpaperChange({ backgroundUrls: newBackgroundUrls });
onWallpaperChange({ currentWallpapers: newcurrentWallpapers });
} catch (error) {
alert('Error deleting wallpaper. Please try again.');
console.error(error);
}
};
const allWallpapers = [...baseWallpapers, ...userWallpapers];
@@ -226,7 +241,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
<div
className={`fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-in-out ${
className={`fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-250 ease-ios ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
onClick={handleClose}
@@ -234,7 +249,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<div
ref={menuRef}
className={`fixed top-0 right-0 h-full w-full max-w-lg bg-black/50 backdrop-blur-xl border-l border-white/10 text-white flex flex-col transition-transform duration-300 ease-in-out transform ${
className={`fixed top-0 right-0 h-full w-full max-w-lg bg-black/50 backdrop-blur-xl border-l border-white/10 text-white flex flex-col transition-transform duration-300 ease-spring transform ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
>
@@ -294,30 +309,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
]}
/>
</div>
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Subtitle</label>
<input
type="text"
name="subtitle"
value={config.subtitle}
onChange={handleChange}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Subtitle Size</label>
<Dropdown
name="subtitleSize"
value={config.subtitleSize}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
<Dropdown
@@ -365,35 +356,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,12 +429,33 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<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"
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"
@@ -498,6 +492,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
</div>
</div>
</div>
</>
)}
</div>
)}
@@ -646,10 +642,10 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
</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 text-white font-bold py-2 px-6 rounded-lg">
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 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 text-white font-bold py-2 px-6 rounded-lg">
<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>

View File

@@ -79,7 +79,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
>
<span className="truncate">{selectedOptionLabel}</span>
<svg
className={`w-5 h-5 transition-transform duration-300 ease-in-out ${isOpen ? 'rotate-180' : 'rotate-0'}`}
className={`w-5 h-5 transition-transform duration-200 ease-ios ${isOpen ? 'rotate-180' : 'rotate-0'}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
@@ -95,14 +95,14 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
{isOpen && (
<ul
className="absolute z-10 mt-1 w-full bg-black/70 backdrop-blur-xl border border-white/20 rounded-lg shadow-2xl overflow-hidden animate-in slide-in-from-top-2 fade-in duration-200"
className="absolute z-10 mt-1 w-full bg-black/70 backdrop-blur-xl border border-white/20 rounded-lg shadow-2xl overflow-hidden animate-in slide-in-from-top-2 fade-in duration-150"
role="listbox"
>
{options.map((option) => (
<li
key={option.value}
onClick={() => handleOptionClick(option.value)}
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-ios flex items-center
${
isSelected(option.value)
? 'bg-cyan-500/20 text-cyan-300'

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;
@@ -12,11 +12,11 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange }) => {
return (
<div
className={`w-14 h-8 flex items-center rounded-full p-1 cursor-pointer transition-colors duration-300 ${checked ? 'bg-cyan-500' : 'bg-gray-600'}`}
className={`w-14 h-8 flex items-center rounded-full p-1 cursor-pointer transition-colors duration-200 ease-ios ${checked ? 'bg-cyan-500' : 'bg-gray-600'}`}
onClick={handleToggle}
>
<div
className={`bg-white w-6 h-6 rounded-full shadow-md transform transition-transform duration-300 ${checked ? 'translate-x-6' : 'translate-x-0'}`}
className={`bg-white w-6 h-6 rounded-full shadow-md transform transition-transform duration-200 ease-spring ${checked ? 'translate-x-6' : 'translate-x-0'}`}
/>
</div>
);

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';
@@ -76,6 +76,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
};
const handleSave = () => {
console.log({ id: website?.id, name, url, icon });
onSave({ id: website?.id, name, url, icon });
};

17
components/WebsiteTile.tsx Executable file → Normal file
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:
@@ -74,13 +73,13 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
return (
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-300 ease-in-out`}>
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios`}>
<a
href={isEditing ? undefined : website.url}
target="_self"
rel="noopener noreferrer"
onClick={handleClick}
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-300 ease-in-out hover:scale-105 hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-200 ease-ios hover:scale-[1.04] active:scale-[0.96] hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center mb-6">
@@ -90,11 +89,11 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</svg>
</div>
)}
<div className={`flex items-center transition-all duration-300 ease-in ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
<div className={`transition-all duration-300 ease-in ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
<img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
<div className={`flex items-center transition-all duration-200 ease-ios ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
<div className={`transition-all duration-200 ease-ios ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
<img src={website.icon} alt={`${website.name} icon`} className={`object-contain w-full h-full`} />
</div>
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-200 ease-ios ${isLoading ? 'text-sm' : ''}`}>
{website.name}
</span>
</div>

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;
@@ -9,7 +9,7 @@ const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) =>
<div className="absolute top-4 right-4">
<button
onClick={onClick}
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 active:scale-90 transition-all duration-200 ease-ios"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface EditButtonProps {
isEditing: boolean;
@@ -10,7 +10,7 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
<div className="absolute top-4 left-4">
<button
onClick={onClick}
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 active:scale-90 transition-all duration-200 ease-ios"
style={{ fontSize: '12px' }}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">

View File

@@ -1,4 +1,3 @@
import React from 'react';
import Clock from '../Clock';
import { Config } from '../../types';
@@ -60,7 +59,7 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
</div>
)}
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
{(config.title || config.subtitle) && (
{config.title && (
<div className="text-center">
<h1
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
@@ -68,12 +67,6 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
>
{config.title}
</h1>
<p
className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
>
{config.subtitle}
</p>
</div>
)}
</div>

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

0
constants.tsx Executable file → Normal file
View File

0
icon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 763 KiB

View File

@@ -1 +1,6 @@
@import "tailwindcss";
@theme {
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}

0
index.html Executable file → Normal file
View File

0
index.tsx Executable file → Normal file
View File

57
notes-widget-plan.md Normal file
View File

@@ -0,0 +1,57 @@
# Notes Widget Implementation Plan
## Overview
This document outlines the implementation plan for a Notes / Scratchpad Widget feature that:
1. Provides a text area that saves content to local storage automatically
2. Includes bold and italic formatting buttons
3. Has a toggle icon to show/hide the widget
4. Has a glassy/frosty design with strong blur
## Implementation Steps
### 1. Update Config Interface
In `types.ts`, add a new `notesWidget` configuration object:
```typescript
notesWidget: {
enabled: boolean;
}
```
### 2. Update ConfigurationModal Component
In `components/ConfigurationModal.tsx`:
- Add `notesWidget` to the config state initialization (lines 18-44)
- Add a new "Notes" tab to the tab navigation (lines 259-284)
- Add a toggle switch for notesWidget.enabled in the Notes tab (lines 552-560)
- Add the Notes tab content with the toggle switch
### 3. Create NotesWidget Component
Create a new file `components/NotesWidget.tsx`:
- Text area for notes input
- Bold and italic formatting buttons
- Toggle icon for showing/hiding the widget
- Glassy/frosty design with strong blur
- Auto-save to localStorage using `userNotes` key
### 4. Update App Component
In `App.tsx`:
- Add notesWidget to the defaultConfig (lines 14-35)
- Add the NotesWidget component to the render output when enabled in config (lines 250-251)
- Add logic to save/load notes from localStorage
## Technical Details
### Configuration Structure
The configuration will be stored in the same way as other widgets:
```typescript
config: {
notesWidget: {
enabled: boolean;
}
}
```
### Local Storage Key
All notes text will be saved to localStorage with the key `userNotes`.
### UI Design
- Widget will be positioned on the left side of the screen
- Vertically centered using flexbox
- Glassy/frosty design using backdrop-blur and background transparency
- Strong blur effect (backdrop-blur-2xl or similar)
- Toggle icon will be positioned on the left edge of the widget
- When hidden, icon shows a "show" indicator
- When open, icon shows a "hide" indicator
### Formatting Buttons
- Bold button (B)
- Italic button (I)
- These will use HTML formatting (bold/italic tags) or rich text approach

616
package-lock.json generated
View File

@@ -10,14 +10,14 @@
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"preact": "^10.26.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^22.14.0",
"@types/react": "^19.1.8",
"@vitejs/plugin-react": "^4.7.0",
"@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
@@ -52,13 +52,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -108,14 +108,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -124,6 +124,19 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
@@ -152,14 +165,14 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
"@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -184,9 +197,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
@@ -204,9 +217,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -238,13 +251,13 @@
}
},
"node_modules/@babel/parser": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.0"
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -253,14 +266,14 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"node_modules/@babel/plugin-syntax-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -269,14 +282,34 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"node_modules/@babel/plugin-transform-react-jsx": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-development": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
"integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -295,33 +328,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
"@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.0",
"@babel/parser": "^7.29.0",
"@babel/template": "^7.28.6",
"@babel/types": "^7.29.0",
"debug": "^4.3.1"
},
"engines": {
@@ -329,14 +362,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -807,9 +840,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -822,13 +855,121 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"node_modules/@preact/preset-vite": {
"version": "2.10.5",
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz",
"integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1",
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
"@prefresh/vite": "^2.4.11",
"@rollup/pluginutils": "^5.0.0",
"babel-plugin-transform-hook-names": "^1.0.2",
"debug": "^4.4.3",
"magic-string": "^0.30.21",
"picocolors": "^1.1.1",
"vite-prerender-plugin": "^0.5.8",
"zimmerframe": "^1.1.4"
},
"peerDependencies": {
"@babel/core": "7.x",
"vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/@prefresh/babel-plugin": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz",
"integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@prefresh/core": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz",
"integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"preact": "^10.0.0 || ^11.0.0-0"
}
},
"node_modules/@prefresh/utils": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz",
"integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==",
"dev": true,
"license": "MIT"
},
"node_modules/@prefresh/vite": {
"version": "2.4.12",
"resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz",
"integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.22.1",
"@prefresh/babel-plugin": "^0.5.2",
"@prefresh/core": "^1.5.0",
"@prefresh/utils": "^1.2.0",
"@rollup/pluginutils": "^4.2.1"
},
"peerDependencies": {
"preact": "^10.4.0 || ^11.0.0-0",
"vite": ">=2.0.0"
}
},
"node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
"integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"estree-walker": "^2.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/@prefresh/vite/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.45.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz",
@@ -1365,51 +1506,6 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1427,13 +1523,23 @@
}
},
"node_modules/@types/react": {
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
@@ -1442,27 +1548,6 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -1501,6 +1586,23 @@
"postcss": "^8.1.0"
}
},
"node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
"integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@babel/core": "^7.12.10"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -1580,17 +1682,47 @@
"tiny-invariant": "^1.0.6"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1614,6 +1746,65 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.187",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
@@ -1634,6 +1825,19 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
@@ -1685,6 +1889,13 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
@@ -1743,6 +1954,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
@@ -1785,6 +2006,13 @@
"node": ">=6"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -2031,12 +2259,12 @@
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/minipass": {
@@ -2100,6 +2328,17 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-html-parser": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
"integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"he": "1.2.0"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -2117,6 +2356,19 @@
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2170,6 +2422,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
"version": "10.29.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -2181,6 +2443,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2190,6 +2453,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -2220,16 +2484,6 @@
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -2279,7 +2533,8 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/semver": {
"version": "6.3.1",
@@ -2291,6 +2546,26 @@
"semver": "bin/semver.js"
}
},
"node_modules/simple-code-frame": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz",
"integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"kolorist": "^1.6.0"
}
},
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2300,6 +2575,16 @@
"node": ">=0.10.0"
}
},
"node_modules/stack-trace": {
"version": "1.0.0-pre2",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz",
"integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/tailwindcss": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
@@ -2489,6 +2774,24 @@
}
}
},
"node_modules/vite-prerender-plugin": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz",
"integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"kolorist": "^1.8.0",
"magic-string": "0.x >= 0.26.0",
"node-html-parser": "^6.1.12",
"simple-code-frame": "^1.3.0",
"source-map": "^0.7.4",
"stack-trace": "^1.0.0-pre2"
},
"peerDependencies": {
"vite": "5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -2497,6 +2800,13 @@
"engines": {
"node": ">=18"
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
}

6
package.json Executable file → Normal file
View File

@@ -11,14 +11,14 @@
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"preact": "^10.26.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^22.14.0",
"@types/react": "^19.1.8",
"@vitejs/plugin-react": "^4.7.0",
"@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",

0
public/favicon.ico Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 763 KiB

42
scripts/check_virustotal.sh Executable file → Normal file
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,7 +55,7 @@ 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
@@ -63,40 +63,40 @@ 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')
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.status')
if [ "$RESPONSE_CODE" == "1" ]; then
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"

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
],
}

0
tsconfig.json Executable file → Normal file
View File

4
types.ts Executable file → Normal file
View File

@@ -26,14 +26,12 @@ export interface Wallpaper {
export interface Config {
title: string;
subtitle: string;
backgroundUrls: string[];
currentWallpapers: string[];
wallpaperFrequency: string;
wallpaperBlur: number;
wallpaperBrightness: number;
wallpaperOpacity: number;
titleSize: string;
subtitleSize: string;
alignment: string;
horizontalAlignment: string;
clock: {

4
vite.config.ts Executable file → Normal file
View File

@@ -1,12 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import preact from '@preact/preset-vite'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [preact(), tailwindcss()],
build: {
rollupOptions: {
input: {

BIN
waves.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB