12 Commits

Author SHA1 Message Date
9b818b05f9 I have to use some actions from v3 apparently
All checks were successful
Build and Release / build (push) Successful in 26s
Build and Release / virus-total-check (push) Successful in 51s
Build and Release / release (push) Successful in 5s
2025-07-30 18:14:21 -03:00
008d2321e5 adding virus total check
Some checks failed
Build and Release / build (push) Failing after 26s
Build and Release / virus-total-check (push) Has been skipped
Build and Release / release (push) Has been skipped
2025-07-30 18:08:27 -03:00
3aff7ffed6 whoopsie
All checks were successful
Build and Release / build (push) Successful in 12s
Build and Release / release (push) Successful in 12s
2025-07-30 17:40:30 -03:00
9e80818fc5 redoing pipelines
All checks were successful
Build and Release / build (push) Successful in 11s
Build and Release / release (push) Has been skipped
2025-07-30 17:40:02 -03:00
140119cb99 changing pipeline 🤓
All checks were successful
Build and Release / build (push) Successful in 11s
Build and Release / release (push) Has been skipped
2025-07-30 17:38:13 -03:00
af7b778561 first release maybe?
All checks were successful
Build and Release / build (push) Successful in 50s
Build and Release / release (push) Has been skipped
2025-07-30 17:29:50 -03:00
2849ed3bb2 to-do!
All checks were successful
Check scripts syntax / build-release (push) Successful in 43s
2025-07-28 17:26:55 -03:00
f1c1b0c6c6 fixing multiple wallpapers 2025-07-28 16:37:45 -03:00
ffdaf06d55 better now
All checks were successful
Check scripts syntax / build-release (push) Successful in 1m12s
2025-07-27 18:35:27 -03:00
05263d0d3a lets test pipeline
All checks were successful
Check scripts syntax / build (push) Successful in 1m13s
2025-07-27 18:29:30 -03:00
905b05e343 refactors! 2025-07-27 18:13:43 -03:00
181fd3b3ec looking better 2025-07-27 18:04:39 -03:00
26 changed files with 921 additions and 302 deletions

View File

@@ -0,0 +1,18 @@
name: Build and Release
on:
push:
branches:
- main
jobs:
build:
if: gitea.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Install JS dependencies
run: npm install
- name: Run build
run: npm run build

View File

@@ -0,0 +1,90 @@
name: Build and Release
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
outputs:
zip-file: vision-start-${{ gitea.ref_name }}.zip
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup required tools
run: sudo apt-get install zip jq curl -y
- name: Install JS dependencies
run: npm install
- name: Run build
run: npm run build
- name: Prepare release
run: |
bash scripts/prepare_release.sh
mv dist vision-start/
mv manifest.json vision-start/
- name: Create zip archive
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: release-zip
path: vision-start-${{ gitea.ref_name }}.zip
virus-total-check:
runs-on: ubuntu-latest
needs: build
outputs:
analysis-url: ${{ steps.vt-check.outputs.analysis-url }}
detection-ratio: ${{ steps.vt-check.outputs.detection-ratio }}
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup required tools
run: sudo apt-get install jq curl -y
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: release-zip
- name: Run VirusTotal check
id: vt-check
env:
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
run: |
# Run the VirusTotal check script and capture output
bash scripts/check_virustotal.sh > vt_output.txt 2>&1
# Extract analysis URL and detection ratio from output
ANALYSIS_URL=$(grep "Analysis URL:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
DETECTION_RATIO=$(grep "Detection ratio:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
# Set outputs for next job
echo "analysis-url=$ANALYSIS_URL" >> $GITEA_OUTPUT
echo "detection-ratio=$DETECTION_RATIO" >> $GITEA_OUTPUT
# Display the full output
cat vt_output.txt
release:
runs-on: ubuntu-latest
needs: [build, virus-total-check]
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: release-zip
- name: Release zip
uses: akkuman/gitea-release-action@v1
with:
body: |
This is the release for version ${{ gitea.ref_name }}.
**Virus Total Analysis URL:** ${{ needs.virus-total-check.outputs.analysis-url }}
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
name: ${{ gitea.ref_name }}
tag_name: ${{ gitea.ref_name }}
files: vision-start-${{ gitea.ref_name }}.zip

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Project specific files
public/icon-metadata.json

226
App.tsx
View File

@@ -1,21 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import WebsiteTile from './components/WebsiteTile';
import ConfigurationModal from './components/ConfigurationModal'; import ConfigurationModal from './components/ConfigurationModal';
import Clock from './components/Clock';
import ServerWidget from './components/ServerWidget'; import ServerWidget from './components/ServerWidget';
import { DEFAULT_CATEGORIES } from './constants'; import { DEFAULT_CATEGORIES } from './constants';
import { Category, Website, Wallpaper } from './types'; import { Category, Website, Wallpaper, Config } from './types';
import Dropdown from './components/Dropdown';
import WebsiteEditModal from './components/WebsiteEditModal'; import WebsiteEditModal from './components/WebsiteEditModal';
import CategoryEditModal from './components/CategoryEditModal'; import CategoryEditModal from './components/CategoryEditModal';
import { PlusCircle, Pencil } from 'lucide-react'; import Header from './components/layout/Header';
import EditButton from './components/layout/EditButton';
import ConfigurationButton from './components/layout/ConfigurationButton';
import CategoryGroup from './components/layout/CategoryGroup';
import { baseWallpapers } from './components/utils/baseWallpapers'; import { baseWallpapers } from './components/utils/baseWallpapers';
const defaultConfig: Config = {
const defaultConfig = {
title: 'Vision Start', title: 'Vision Start',
subtitle: 'Your personal portal to the web.', subtitle: 'Your personal portal to the web.',
backgroundUrl: 'https://i.imgur.com/C6ynAtX.jpeg', backgroundUrls: ['https://i.imgur.com/C6ynAtX.jpeg'],
wallpaperFrequency: '1d',
wallpaperBlur: 0, wallpaperBlur: 0,
wallpaperBrightness: 100, wallpaperBrightness: 100,
wallpaperOpacity: 100, wallpaperOpacity: 100,
@@ -54,11 +55,15 @@ const App: React.FC = () => {
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null); const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [config, setConfig] = useState(() => { const [config, setConfig] = useState<Config>(() => {
try { try {
const storedConfig = localStorage.getItem('config'); const storedConfig = localStorage.getItem('config');
if (storedConfig) { if (storedConfig) {
return { ...defaultConfig, ...JSON.parse(storedConfig) }; const parsedConfig = JSON.parse(storedConfig);
if (!parsedConfig.backgroundUrls) {
parsedConfig.backgroundUrls = [parsedConfig.backgroundUrl].filter(Boolean);
}
return { ...defaultConfig, ...parsedConfig };
} }
} catch (error) { } catch (error) {
console.error('Error parsing config from localStorage', error); console.error('Error parsing config from localStorage', error);
@@ -69,9 +74,51 @@ const App: React.FC = () => {
const storedUserWallpapers = localStorage.getItem('userWallpapers'); const storedUserWallpapers = localStorage.getItem('userWallpapers');
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : []; return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
}); });
const [currentWallpaper, setCurrentWallpaper] = useState<string>('');
const allWallpapers = [...baseWallpapers, ...userWallpapers]; const allWallpapers = [...baseWallpapers, ...userWallpapers];
const selectedWallpaper = allWallpapers.find(w => w.url === config.backgroundUrl || w.base64 === config.backgroundUrl);
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(() => { useEffect(() => {
localStorage.setItem('categories', JSON.stringify(categories)); localStorage.setItem('categories', JSON.stringify(categories));
@@ -83,6 +130,10 @@ const App: React.FC = () => {
setIsConfigModalOpen(false); setIsConfigModalOpen(false);
}; };
const handleWallpaperChange = (newConfig: Partial<Config>) => {
setConfig(prev => ({ ...prev, ...newConfig }));
};
const handleSaveWebsite = (website: Partial<Website>) => { const handleSaveWebsite = (website: Partial<Website>) => {
if (editingWebsite) { if (editingWebsite) {
const newCategories = categories.map(category => ({ const newCategories = categories.map(category => ({
@@ -191,51 +242,6 @@ const App: React.FC = () => {
} }
}; };
const getClockSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-3xl';
case 'small':
return 'text-4xl';
case 'medium':
return 'text-5xl';
case 'large':
return 'text-6xl';
default:
return 'text-5xl';
}
};
const getTitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-4xl';
case 'small':
return 'text-5xl';
case 'medium':
return 'text-6xl';
case 'large':
return 'text-7xl';
default:
return 'text-6xl';
}
};
const getSubtitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-lg';
case 'small':
return 'text-xl';
case 'medium':
return 'text-2xl';
case 'large':
return 'text-3xl';
default:
return 'text-2xl';
}
};
const getHorizontalAlignmentClass = (alignment: string) => { const getHorizontalAlignmentClass = (alignment: string) => {
switch (alignment) { switch (alignment) {
case 'left': case 'left':
@@ -250,107 +256,39 @@ const App: React.FC = () => {
}; };
return ( return (
<main <main
className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`} className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
> >
<div <div
className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10" className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
style={{ style={{
backgroundImage: `url('${selectedWallpaper?.url || selectedWallpaper?.base64 || ''}')`, backgroundImage: `url('${currentWallpaper}')`,
filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`, filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
opacity: `${config.wallpaperOpacity}%`, opacity: `${config.wallpaperOpacity}%`,
}} }}
></div> ></div>
<div className="absolute top-4 left-4"> <EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
<button <ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
onClick={() => setIsEditing(!isEditing)}
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
{isEditing ? 'Done' : 'Edit'}
</button>
</div>
<div className="absolute top-4 right-4">
<button
onClick={() => setIsConfigModalOpen(true)}
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white hover:bg-white/25 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-gear-wide" viewBox="0 0 16 16">
<path d="M8.932.727c-.243-.97-1.62-.97-1.864 0l-.071.286a.96.96 0 0 1-1.622.434l-.205-.211c-.695-.719-1.888-.03-1.613.931l.08.284a.96.96 0 0 1-1.186 1.187l-.284-.081c-.96-.275-1.65.918-.931 1.613l.211.205a.96.96 0 0 1-.434 1.622l-.286.071c-.97.243-.97 1.62 0 1.864l.286.071a.96.96 0 0 1 .434 1.622l-.211.205c-.719.695-.03 1.888.931 1.613l.284-.08a.96.96 0 0 1 1.187 1.187l-.081.283c-.275.96.918 1.65 1.613.931l.205-.211a.96.96 0 0 1 1.622.434l.071.286c.243.97 1.62.97 1.864 0l.071-.286a.96.96 0 0 1 1.622-.434l.205.211c.695.719 1.888.03 1.613-.931l-.08-.284a.96.96 0 0 1 1.187-1.187l.283.081c.96.275 1.65-.918.931-1.613l-.211-.205a.96.96 0 0 1 .434-1.622l.286-.071c.97-.243.97-1.62 0-1.864l-.286-.071a.96.96 0 0 1-.434-1.622l.211-.205c.719-.695.03-1.888-.931-1.613l-.284.08a.96.96 0 0 1-1.187-1.186l.081-.284c.275-.96-.918-1.65-1.613-.931l-.205.211a.96.96 0 0 1-1.622-.434zM8 12.997a4.998 4.998 0 1 1 0-9.995 4.998 4.998 0 0 1 0 9.996z"/>
</svg>
</button>
</div>
{/* Absolute top-center Clock */} <Header config={config} />
{config.clock.enabled && (
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
<Clock config={config} getClockSizeClass={getClockSizeClass} />
</div>
)}
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
{(config.title || config.subtitle) && (
<div className="text-center">
<h1
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
>
{config.title}
</h1>
<p
className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
>
{config.subtitle}
</p>
</div>
)}
</div>
<div className="flex flex-col gap-8 w-full mt-16"> <div className="flex flex-col gap-8 w-full mt-16">
{categories.map((category) => ( {categories.map((category) => (
<div key={category.id} className="w-full"> <CategoryGroup
<div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}> key={category.id}
<h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2> category={category}
{isEditing && ( isEditing={isEditing}
<button setEditingCategory={setEditingCategory}
onClick={() => { setIsCategoryModalOpen={setIsCategoryModalOpen}
setEditingCategory(category); setAddingWebsite={setAddingWebsite}
setIsCategoryModalOpen(true); setEditingWebsite={setEditingWebsite}
}} handleMoveWebsite={handleMoveWebsite}
className="ml-2 text-white/50 hover:text-white transition-colors" getHorizontalAlignmentClass={getHorizontalAlignmentClass}
> config={config}
<Pencil size={20} /> />
</button>
)}
</div>
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
{category.websites.map((website) => (
<WebsiteTile
key={website.id}
website={website}
isEditing={isEditing}
onEdit={setEditingWebsite}
onMove={handleMoveWebsite}
tileSize={config.tileSize}
/>
))}
{isEditing && (
<button
onClick={() => setAddingWebsite(category)}
className="text-white/50 hover:text-white transition-colors"
>
<PlusCircle size={48} />
</button>
)}
</div>
</div>
))} ))}
{isEditing && ( {isEditing && (
<div className="flex justify-center"> <div className={`flex justify-center transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
<button <button
onClick={() => { onClick={() => {
setEditingCategory(null); setEditingCategory(null);
@@ -358,7 +296,10 @@ const App: React.FC = () => {
}} }}
className="text-white/50 hover:text-white transition-colors" className="text-white/50 hover:text-white transition-colors"
> >
<PlusCircle size={48} /> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>
</button> </button>
</div> </div>
)} )}
@@ -401,6 +342,7 @@ const App: React.FC = () => {
currentConfig={config} currentConfig={config}
onClose={() => setIsConfigModalOpen(false)} onClose={() => setIsConfigModalOpen(false)}
onSave={handleSaveConfig} onSave={handleSaveConfig}
onWallpaperChange={handleWallpaperChange}
/> />
)} )}
</main> </main>

39
GEMINI.md Normal file
View File

@@ -0,0 +1,39 @@
# Vision Startpage Project
## Overview
This project is a highly customizable and stylish startpage built with React. The goal is to create a visually appealing and functional dashboard that serves as a user's entry point to the web.
## Key Features & Design Principles
* **Technology Stack:** The project is built using React and TypeScript.
* **Aesthetics:** The user interface should have a modern, "glassy" or "frosted glass" look (neumorphism/glassmorphism). This involves using transparency, blur effects, and subtle shadows to create a sense of depth.
* **Typography:** Specific font families and types will be used to maintain a consistent and elegant design.
* **Modals:** All modals in the application should follow a specific and consistent design language, contributing to the overall user experience.
* **Production Quality Code:** All code must be written to production standards, with a strong emphasis on readability, maintainability, and performance.
* **Creative & Beautiful Code:** Code should not only be functional but also well-structured, elegant, and creative.
* **Dropdown Component:** A reusable dropdown component (`components/Dropdown.tsx`) has been created for consistent styling and functionality across the application. It features a dark, glassy look with a custom arrow icon.
**Usage Example:**
```typescript jsx
import Dropdown from './components/Dropdown';
// ... inside a React component
<Dropdown
options={[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]}
value={selectedValue}
onChange={handleSelectChange}
name="myDropdown"
/>
```
## Development Guidelines
* Follow the existing code style and conventions.
* Ensure all new components and features align with the established design principles.
* Write clean, commented, and reusable code.
* DO NOT run `npm run dev`, and instead, run `npm run build`.

100
README.md
View File

@@ -1,17 +1,95 @@
# Vision Start # Vision Start
#### Small startpage #### A glassmorphism-looking like, modern and customizable startpage built with React.
## Predefined themes ## Screenshots
1. Abstract ![Vision Start with the Dark background](screenshots/dark-page.png)
2. Aurora (Vista vibes) ![Editing page with Abstract Red background](screenshots/editing-abstract-red.png)
3. Mountain ![Configuration Tab opened](screenshots/configuration-abstract-red.png)
## Run Locally ## Installing
**Prerequisites:** Node.js Vision Start is not yet available on Chrome Web Store, but it can be installed manually:
1. Go to https://git.ivanch.me/ivanch/vision-start/releases/latest
2. Download the latest `vision-start-[version].zip` file
3. Extract the zip file, you will have a `vision-start` folder
4. Go to chrome://extensions/
5. Enable "Developer mode" in the top right corner
6. Click on "Load unpacked" and select the `vision-start` folder you extracted in step 3
7. The extension should now be installed! Just open a new tab to see it in action.
1. Install dependencies: ## Backgrounds
`npm install`
2. Run the app: It comes with a selection of some nice pre-defined backgrounds. You can also upload up to one image to it.
`npm run dev`
* **Abstract**
* **Abstract Red**
* **Beach**
* **Dark**
* **Mountain**
* **Waves**
## Features
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
* **Elegant Clock:** A clock because all startpages have one.
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
* **Glassmorphism UI:** A modern and stylish interface with a frosted glass effect.
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
* **Future**: a long to do list :(
## Running Locally
**Prerequisites:** Node.js
1. Clone the repository:
```bash
git clone https://gitea.com/ivan/vision-start.git
cd vision-start
```
2. Install dependencies:
```bash
npm install
```
3. Run the development server:
```bash
npm run dev
```
## To-do
* [x] Multiple Wallpapers
* [x] Remake icons
* [] Increase offline compatibility (might not be possible)
* Use chrome.storage.local for user wallpapers -- this one is
* Use chrome.storage.local for some logos -- a bit hard
* Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
* Dynamic Weather Widget
* A box with information about the current weather, with manual entry on the location
* Display current temperature, weather condition (e.g., "Sunny," "Cloudy"), and a corresponding icon
* Optionally, show a 3-day forecast when clicked or hovered
* Search Bar Widget
* Positioned to the right or left side of the clock, display a nice search bar
* Behaviour:
* When not in focus, it could be highly transparent with just a faint border and a search icon.
* When clicked, it would smoothly expand and become slightly more opaque, with a soft glow around the border (similar to the existing ones)
* Config to allow changing the default search engine
* Draggable & Resizable Grid System
* Allow users to drag and drop all widgets (Clock, Website Tiles, Weather, Title, etc.) into any position on a grid
* Notes / Scratchpad Widget
* A simple text area that saves its content to local storage automatically.
* Maybe some extra formatting (bold, italic, increase font size, etc).
* Theme-ing
* A Light/Dark Mode toggle
* Custom Accent Colors
* Selection of 6-8 accent colors that are guaranteed to look good with both Light and Dark themes
* Define CSS variables for the accent color
* Dynamic Wallpaper-Based Theming
* Automatically adapt the UI's accent color to match the current wallpaper
* Minimal feel toggle
* Disable title & subtitle and search widget
* Tiles become small stylish lines
From a technical side:
* Refactor everything :(
* Add small nginx demo (with docker)

View File

@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import ToggleSwitch from './ToggleSwitch'; import ToggleSwitch from './ToggleSwitch';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { Server, Wallpaper } from '../types'; import { Server, Wallpaper } from '../types';
import { Trash } from 'lucide-react';
import Dropdown from './Dropdown'; import Dropdown from './Dropdown';
import { baseWallpapers } from './utils/baseWallpapers'; import { baseWallpapers } from './utils/baseWallpapers';
@@ -11,9 +10,10 @@ interface ConfigurationModalProps {
onClose: () => void; onClose: () => void;
onSave: (config: any) => void; onSave: (config: any) => void;
currentConfig: any; currentConfig: any;
onWallpaperChange: (newConfig: Partial<any>) => void;
} }
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig }) => { const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig, onWallpaperChange }) => {
const [config, setConfig] = useState({ const [config, setConfig] = useState({
...currentConfig, ...currentConfig,
titleSize: currentConfig.titleSize || 'medium', titleSize: currentConfig.titleSize || 'medium',
@@ -37,6 +37,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
format: 'h:mm A', format: 'h:mm A',
...currentConfig.clock, ...currentConfig.clock,
}, },
backgroundUrls: currentConfig.backgroundUrls || [],
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
}); });
const [activeTab, setActiveTab] = useState('general'); const [activeTab, setActiveTab] = useState('general');
const [newServerName, setNewServerName] = useState(''); const [newServerName, setNewServerName] = useState('');
@@ -46,6 +48,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]); const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { useEffect(() => {
@@ -63,6 +66,14 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
useEffect(() => {
return () => {
if (!isSaving.current) {
onWallpaperChange({ backgroundUrls: currentConfig.backgroundUrls });
}
};
}, []);
const handleClose = () => { const handleClose = () => {
setIsVisible(false); setIsVisible(false);
setTimeout(() => { setTimeout(() => {
@@ -70,7 +81,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
}, 300); // This duration should match the transition duration }, 300); // This duration should match the transition duration
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
const { name, value } = e.target; const { name, value } = e.target;
if (name.startsWith('serverWidget.')) { if (name.startsWith('serverWidget.')) {
const field = name.split('.')[1]; const field = name.split('.')[1];
@@ -89,6 +100,10 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
} }
}; };
useEffect(() => {
onWallpaperChange({ backgroundUrls: config.backgroundUrls });
}, [config.backgroundUrls]);
const handleClockToggleChange = (checked: boolean) => { const handleClockToggleChange = (checked: boolean) => {
setConfig({ ...config, clock: { ...config.clock, enabled: checked } }); setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
}; };
@@ -158,7 +173,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
const updatedUserWallpapers = [...userWallpapers, newWallpaper]; const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers); setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers)); localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, backgroundUrl: newWallpaperUrl }); setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
setNewWallpaperName(''); setNewWallpaperName('');
setNewWallpaperUrl(''); setNewWallpaperUrl('');
@@ -187,23 +202,23 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
}; };
setUserWallpapers([...updatedUserWallpapers, newWallpaper]); setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper])); localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
setConfig({ ...config, backgroundUrl: base64 }); setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, base64] });
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}; };
const handleDeleteWallpaper = (wallpaper: Wallpaper) => { const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name); const wallpaperIdentifier = wallpaper.url || wallpaper.base64;
const updatedUserWallpapers = userWallpapers.filter(w => (w.url || w.base64) !== wallpaperIdentifier);
setUserWallpapers(updatedUserWallpapers); setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers)); localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
if (config.backgroundUrl === (wallpaper.url || wallpaper.base64)) { const newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
const nextWallpaper = baseWallpapers[0] || updatedUserWallpapers[0];
if (nextWallpaper) { const newConfig = { ...config, backgroundUrls: newBackgroundUrls };
setConfig({ ...config, backgroundUrl: nextWallpaper.url || nextWallpaper.base64 }); setConfig(newConfig);
} onWallpaperChange({ backgroundUrls: newBackgroundUrls });
}
}; };
const allWallpapers = [...baseWallpapers, ...userWallpapers]; const allWallpapers = [...baseWallpapers, ...userWallpapers];
@@ -350,23 +365,27 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label> <label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown <Dropdown
name="backgroundUrl" name="backgroundUrls"
value={config.backgroundUrl} value={config.backgroundUrls}
onChange={handleChange} onChange={handleChange}
multiple
options={allWallpapers.map(w => ({ options={allWallpapers.map(w => ({
value: w.url || w.base64 || '', value: w.url || w.base64 || '',
label: ( label: (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between w-full">
{w.name} <span>{w.name}</span>
{!baseWallpapers.includes(w) && ( {!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && (
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteWallpaper(w); handleDeleteWallpaper(w);
}} }}
className="text-red-500 hover:text-red-400 ml-4" className="text-red-500 hover:text-red-400 ml-4 p-1 rounded-full flex items-center justify-center"
> >
<Trash size={16} /> <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> </button>
)} )}
</div> </div>
@@ -374,6 +393,24 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
}))} }))}
/> />
</div> </div>
{Array.isArray(config.backgroundUrls) && config.backgroundUrls.length > 1 && (
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={handleChange}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
/>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label> <label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -609,7 +646,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
</div> </div>
<div className="p-8 border-t border-white/10"> <div className="p-8 border-t border-white/10">
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<button onClick={() => onSave(config)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg"> <button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save & Close Save & Close
</button> </button>
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg"> <button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
@@ -622,4 +659,4 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
); );
}; };
export default ConfigurationModal; export default ConfigurationModal;

View File

@@ -2,17 +2,16 @@ import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps { interface DropdownProps {
options: { value: string; label: string }[]; options: { value: string; label: string }[];
value: string; value: string | string[];
onChange: (e: { target: { name: string; value: string } }) => void; onChange: (e: { target: { name: string; value: string | string[] } }) => void;
name?: string; name?: string;
multiple?: boolean;
} }
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...rest }) => { const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, multiple = false, ...rest }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOptionLabel = options.find(option => option.value === value)?.label || '';
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -26,14 +25,46 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
}, []); }, []);
const handleOptionClick = (optionValue: string) => { const handleOptionClick = (optionValue: string) => {
let newValue: string | string[];
if (multiple) {
const currentValues = Array.isArray(value) ? value : [];
if (currentValues.includes(optionValue)) {
newValue = currentValues.filter((v) => v !== optionValue);
} else {
newValue = [...currentValues, optionValue];
}
} else {
newValue = optionValue;
setIsOpen(false);
}
const syntheticEvent = { const syntheticEvent = {
target: { target: {
name: name || '', name: name || '',
value: optionValue, value: newValue,
}, },
}; };
onChange(syntheticEvent); onChange(syntheticEvent as any);
setIsOpen(false); };
const selectedOptionLabel = (() => {
if (multiple) {
if (Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
return options.find((o) => o.value === value[0])?.label || '';
}
return `${value.length} selected`;
}
return 'Select...';
}
return options.find((option) => option.value === value)?.label || 'Select...';
})();
const isSelected = (optionValue: string) => {
if (multiple && Array.isArray(value)) {
return value.includes(optionValue);
}
return optionValue === value;
}; };
return ( return (
@@ -72,12 +103,13 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
key={option.value} key={option.value}
onClick={() => handleOptionClick(option.value)} onClick={() => handleOptionClick(option.value)}
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
${option.value === value ${
? 'bg-cyan-500/20 text-cyan-300' isSelected(option.value)
: 'hover:bg-white/20 hover:text-white hover:shadow-lg' ? 'bg-cyan-500/20 text-cyan-300'
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
}`} }`}
role="option" role="option"
aria-selected={option.value === value} aria-selected={isSelected(option.value)}
> >
<span className="truncate">{option.label}</span> <span className="truncate">{option.label}</span>
</li> </li>
@@ -86,7 +118,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
)} )}
{/* Hidden input to mimic native select behavior for forms */} {/* Hidden input to mimic native select behavior for forms */}
{name && <input type="hidden" name={name} value={value} />} {name && !multiple && <input type="hidden" name={name} value={value as string} />}
</div> </div>
); );
}; };

View File

@@ -1,48 +0,0 @@
import React, { useState, useMemo } from 'react';
import { icons } from 'lucide-react';
interface IconPickerProps {
onSelect: (iconName: string) => void;
}
const IconPicker: React.FC<IconPickerProps> = ({ onSelect }) => {
const [search, setSearch] = useState('');
const filteredIcons = useMemo(() => {
if (!search) {
return Object.keys(icons).slice(0, 50);
}
return Object.keys(icons).filter(name =>
name.toLowerCase().includes(search.toLowerCase())
);
}, [search]);
return (
<div className="bg-gray-800 p-4 rounded-lg">
<input
type="text"
placeholder="Search for an icon..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full p-2 mb-4 bg-gray-700 rounded text-white"
/>
<div className="grid grid-cols-6 gap-4 max-h-60 overflow-y-auto">
{filteredIcons.map(iconName => {
const LucideIcon = icons[iconName as keyof typeof icons];
return (
<div
key={iconName}
onClick={() => onSelect(iconName)}
className="cursor-pointer flex flex-col items-center justify-center p-2 rounded-lg hover:bg-gray-700"
>
<LucideIcon color="white" size={24} />
<span className="text-xs text-white mt-1">{iconName}</span>
</div>
);
})}
</div>
</div>
);
};
export default IconPicker;

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import IconPicker from './IconPicker';
import { getWebsiteIcon } from './utils/iconService'; import { getWebsiteIcon } from './utils/iconService';
import { icons } from 'lucide-react';
interface WebsiteEditModalProps { interface WebsiteEditModalProps {
website?: Website; website?: Website;
@@ -12,21 +10,70 @@ interface WebsiteEditModalProps {
onDelete: () => void; onDelete: () => void;
} }
interface IconMetadata {
name: string;
base: string;
aliases: string[];
categories: string[];
update: {
timestamp: string;
author: {
id: number;
name: string;
};
};
colors: any; // this can be anything I guess
}
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => { const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : ''); const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : ''); const [url, setUrl] = useState(website ? website.url : '');
const [icon, setIcon] = useState(website ? website.icon : ''); const [icon, setIcon] = useState(website ? website.icon : '');
const [showIconPicker, setShowIconPicker] = useState(false); const [iconQuery, setIconQuery] = useState('');
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
useEffect(() => { useEffect(() => {
const fetchIcon = async () => { fetch('/icon-metadata.json')
if (url) { .then(response => response.json())
const fetchedIcon = await getWebsiteIcon(url); .then(data => {
setIcon(fetchedIcon); const iconsArray = Object.entries(data).map(([name, details]) => ({
} name,
}; ...details
fetchIcon(); }));
}, [url]); // Expand colors into separate entries
iconsArray.forEach(icon => {
if (icon.colors) {
const colors = Object.values(icon.colors).filter(key => key !== icon.name);
for (const color of colors) {
const newIcon = { ...icon };
newIcon.name = color;
iconsArray.push(newIcon);
}
}
});
setIconMetadata(iconsArray);
});
}, []);
useEffect(() => {
if (iconQuery && Array.isArray(iconMetadata)) {
const lowerCaseQuery = iconQuery.toLowerCase();
const filtered = iconMetadata
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery))
.slice(0, 50);
setFilteredIcons(filtered);
} else {
setFilteredIcons([]);
}
}, [iconQuery, iconMetadata]);
const fetchIcon = async () => {
if (url) {
const fetchedIcon = await getWebsiteIcon(url);
setIcon(fetchedIcon);
}
};
const handleSave = () => { const handleSave = () => {
onSave({ id: website?.id, name, url, icon }); onSave({ id: website?.id, name, url, icon });
@@ -38,18 +85,22 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
} }
}; };
const LucideIcon = icons[icon as keyof typeof icons];
return ( return (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}> <div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white"> <div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2> <h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{LucideIcon ? ( {icon ? (
<LucideIcon className="h-24 w-24 text-white" />
) : (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" /> <img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
) : (
<div className="h-24 w-24 bg-white/10 rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
</svg>
</div>
)} )}
</div> </div>
<input <input
@@ -67,25 +118,44 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400" className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <div className="relative w-full">
type="text" <input
placeholder="Icon URL or name" type="text"
value={icon} placeholder="Icon URL or name"
onChange={(e) => setIcon(e.target.value)} value={icon}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full" onChange={(e) => {
/> setIcon(e.target.value);
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg"> setIconQuery(e.target.value);
{showIconPicker ? 'Close' : 'Select Icon'} }}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
/>
{filteredIcons.length > 0 && (
<div className="absolute z-10 w-full bg-gray-800 rounded-lg mt-1 max-h-60 overflow-y-auto">
{filteredIcons.map(iconData => (
<div
key={iconData.name}
onClick={() => {
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
setIcon(iconUrl);
setFilteredIcons([]);
}}
className="cursor-pointer flex items-center p-2 hover:bg-gray-700"
>
<img
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
alt={iconData.name}
className="h-6 w-6 mr-2"
/>
<span>{iconData.name}</span>
</div>
))}
</div>
)}
</div>
<button onClick={fetchIcon} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
Fetch
</button> </button>
</div> </div>
{showIconPicker && (
<IconPicker
onSelect={(iconName) => {
setIcon(iconName);
setShowIconPicker(false);
}}
/>
)}
</div> </div>
<div className="flex justify-between items-center mt-8"> <div className="flex justify-between items-center mt-8">
<div> <div>
@@ -109,4 +179,4 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
); );
}; };
export default WebsiteEditModal; export default WebsiteEditModal;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import { icons, ArrowLeft, ArrowRight, Pencil } from 'lucide-react';
interface WebsiteTileProps { interface WebsiteTileProps {
website: Website; website: Website;
@@ -23,21 +23,37 @@ const getTileSizeClass = (size: string | undefined) => {
} }
}; };
const getIconSize = (size: string | undefined) => {
// Returns normal icon size in px
const getIconPixelSize = (size: string | undefined): number => {
switch (size) { switch (size) {
case 'small': case 'small':
return 8; return 32;
case 'medium': case 'medium':
return 10; return 40;
case 'large': case 'large':
return 12; return 48;
default: default:
return 10; return 40;
} }
} };
// Returns loading icon size in px
const getIconLoadingPixelSize = (size: string | undefined): number => {
switch (size) {
case 'small':
return 24;
case 'medium':
return 32;
case 'large':
return 40;
default:
return 32;
}
};
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => { const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const LucideIcon = icons[website.icon as keyof typeof icons];
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
@@ -46,7 +62,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
return; return;
} }
setIsLoading(true); setIsLoading(true);
// Simulate loading time (dev purpose) // Simulate loading time (dev purpose)
// e.preventDefault(); // e.preventDefault();
// setTimeout(() => { // setTimeout(() => {
@@ -54,8 +70,8 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
// }, 3500); // Small delay to show spinner before navigation // }, 3500); // Small delay to show spinner before navigation
}; };
const iconSizeClass = `w-${getIconSize(tileSize)} h-${getIconSize(tileSize)}`; const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
const iconSizeLoadingClass = `w-${getIconSize(tileSize) - 4} h-${getIconSize(tileSize) - 4}`; const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
return ( return (
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-300 ease-in-out`}> <div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-300 ease-in-out`}>
@@ -76,11 +92,7 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
)} )}
<div className={`flex items-center transition-all duration-300 ease-in ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}> <div className={`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}`}> <div className={`transition-all duration-300 ease-in ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
{LucideIcon ? ( <img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
<LucideIcon className={`text-white ${isLoading ? iconSizeLoadingClass : iconSizeClass}`} />
) : (
<img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
)}
</div> </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-300 ease-in ${isLoading ? 'text-sm' : ''}`}>
{website.name} {website.name}
@@ -89,9 +101,15 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</a> </a>
{isEditing && ( {isEditing && (
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2"> <div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><ArrowLeft size={16} /></button> <button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-left" viewBox="0 0 16 16">
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><Pencil size={16} /></button> <path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><ArrowRight size={16} /></button> </svg></button>
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg></button>
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-right" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
</svg></button>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,76 @@
import React from 'react';
import WebsiteTile from '../WebsiteTile';
import { Category, Website } from '../../types';
interface CategoryGroupProps {
category: Category;
isEditing: boolean;
setEditingCategory: (category: Category) => void;
setIsCategoryModalOpen: (isOpen: boolean) => void;
setAddingWebsite: (category: Category) => void;
setEditingWebsite: (website: Website) => void;
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
getHorizontalAlignmentClass: (alignment: string) => string;
config: {
horizontalAlignment: string;
tileSize?: string;
};
}
const CategoryGroup: React.FC<CategoryGroupProps> = ({
category,
isEditing,
setEditingCategory,
setIsCategoryModalOpen,
setAddingWebsite,
setEditingWebsite,
handleMoveWebsite,
getHorizontalAlignmentClass,
config,
}) => {
return (
<div key={category.id} className="w-full">
<div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
<h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
{isEditing && (
<button
onClick={() => {
setEditingCategory(category);
setIsCategoryModalOpen(true);
}}
className={`ml-2 text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg>
</button>
)}
</div>
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
{category.websites.map((website) => (
<WebsiteTile
key={website.id}
website={website}
isEditing={isEditing}
onEdit={setEditingWebsite}
onMove={handleMoveWebsite}
tileSize={config.tileSize}
/>
))}
{isEditing && (
<button
onClick={() => setAddingWebsite(category)}
className={`text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
</svg>
</button>
)}
</div>
</div>
);
};
export default CategoryGroup;

View File

@@ -0,0 +1,23 @@
import React from 'react';
interface ConfigurationButtonProps {
onClick: () => void;
}
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
return (
<div className="absolute top-4 right-4">
<button
onClick={onClick}
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09c.7 0 1.31-.4 1.51-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.51 1.31.61 1.82.33.51-.28 1-.81 1-1.51V3a2 2 0 1 1 4 0v.09c0 .7.49 1.23 1 1.51.51.28 1.31.18 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82c.2.6.81 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.7 0-1.31.4-1.51 1z"/>
</svg>
</button>
</div>
);
};
export default ConfigurationButton;

View File

@@ -0,0 +1,25 @@
import React from 'react';
interface EditButtonProps {
isEditing: boolean;
onClick: () => void;
}
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
return (
<div className="absolute top-4 left-4">
<button
onClick={onClick}
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
style={{ fontSize: '12px' }}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
{isEditing ? 'Done' : ''}
</button>
</div>
);
};
export default EditButton;

View File

@@ -0,0 +1,84 @@
import React from 'react';
import Clock from '../Clock';
import { Config } from '../../types';
interface HeaderProps {
config: Config;
}
const getClockSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-3xl';
case 'small':
return 'text-4xl';
case 'medium':
return 'text-5xl';
case 'large':
return 'text-6xl';
default:
return 'text-5xl';
}
};
const getTitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-4xl';
case 'small':
return 'text-5xl';
case 'medium':
return 'text-6xl';
case 'large':
return 'text-7xl';
default:
return 'text-6xl';
}
};
const getSubtitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-lg';
case 'small':
return 'text-xl';
case 'medium':
return 'text-2xl';
case 'large':
return 'text-3xl';
default:
return 'text-2xl';
}
};
const Header: React.FC<HeaderProps> = ({ config }) => {
return (
<>
{config.clock.enabled && (
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
<Clock config={config} getClockSizeClass={getClockSizeClass} />
</div>
)}
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
{(config.title || config.subtitle) && (
<div className="text-center">
<h1
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
>
{config.title}
</h1>
<p
className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
>
{config.subtitle}
</p>
</div>
)}
</div>
</>
);
};
export default Header;

View File

@@ -14,12 +14,16 @@ export const baseWallpapers: Wallpaper[] = [
name: 'Beach', name: 'Beach',
url: 'https://wallpapershome.com/images/pages/pic_h/615.jpg' url: 'https://wallpapershome.com/images/pages/pic_h/615.jpg'
}, },
{
name: 'Dark',
url: 'https://i.imgur.com/qHlRO0s.jpeg'
},
{ {
name: 'Mountain', name: 'Mountain',
url: 'https://i.imgur.com/yHfOZUd.jpeg' url: 'https://i.imgur.com/yHfOZUd.jpeg'
}, },
{ {
name: 'Waves', name: 'Waves',
url: '/waves.jpg', url: 'https://i.imgur.com/E8uxZ7R.png',
}, },
]; ];

BIN
icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

10
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"lucide-react": "^0.525.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },
@@ -2031,15 +2030,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",

View File

@@ -11,7 +11,6 @@
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"lucide-react": "^0.525.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0"
}, },

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

BIN
screenshots/dark-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

113
scripts/check_virustotal.sh Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Script to check a file against VirusTotal API
# Requires: curl, jq
# Environment variable: virustotal_apikey
set -e
# Configuration
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
API_KEY="${virustotal_apikey}"
BASE_URL="https://www.virustotal.com/vtapi/v2"
# Check if API key is set
if [ -z "$API_KEY" ]; then
echo "Error: virustotal_apikey environment variable is not set"
exit 1
fi
# Check if file exists
if [ ! -f "$FILE_PATH" ]; then
echo "Error: File $FILE_PATH not found"
exit 1
fi
# Check if required tools are available
if ! command -v curl &> /dev/null; then
echo "Error: curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed"
exit 1
fi
echo "Uploading $FILE_PATH to VirusTotal for analysis..."
# Upload file to VirusTotal
UPLOAD_RESPONSE=$(curl -s -X POST \
-F "apikey=$API_KEY" \
-F "file=@$FILE_PATH" \
"$BASE_URL/file/scan")
# Extract scan_id from response
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.scan_id')
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
echo "Error: Failed to upload file or get scan ID"
echo "Response: $UPLOAD_RESPONSE"
exit 1
fi
echo "File uploaded successfully. Scan ID: $SCAN_ID"
echo "Waiting for analysis to complete..."
# Wait for analysis to complete and get results
MAX_ATTEMPTS=30
ATTEMPT=0
SLEEP_INTERVAL=10
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
echo "Checking analysis status (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
# Get scan report
REPORT_RESPONSE=$(curl -s -X POST \
-d "apikey=$API_KEY" \
-d "resource=$SCAN_ID" \
"$BASE_URL/file/report")
# Check if analysis is complete
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.response_code')
if [ "$RESPONSE_CODE" == "1" ]; then
# Analysis complete
echo "Analysis completed!"
# Extract results
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.positives')
TOTAL=$(echo "$REPORT_RESPONSE" | jq -r '.total')
PERMALINK=$(echo "$REPORT_RESPONSE" | jq -r '.permalink')
echo "Analysis URL: $PERMALINK"
echo "Detection ratio: $POSITIVES/$TOTAL"
# Check if file is safe
if [ "$POSITIVES" -eq 0 ]; then
echo "✅ File is clean (no threats detected)"
exit 0
else
echo "❌ File contains threats ($POSITIVES detections out of $TOTAL scanners)"
exit 1
fi
elif [ "$RESPONSE_CODE" == "0" ]; then
# File not found or analysis not complete yet
echo "Analysis still in progress..."
elif [ "$RESPONSE_CODE" == "-2" ]; then
# Still queued for analysis
echo "File still queued for analysis..."
else
echo "Unexpected response code: $RESPONSE_CODE"
echo "Response: $REPORT_RESPONSE"
exit 1
fi
ATTEMPT=$((ATTEMPT + 1))
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
sleep $SLEEP_INTERVAL
fi
done
echo "Timeout: Analysis did not complete within expected time"
exit 1

View File

@@ -0,0 +1 @@
wget https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json

View File

@@ -1,4 +1,3 @@
export interface Website { export interface Website {
id: string; id: string;
name: string; name: string;
@@ -24,3 +23,29 @@ export interface Wallpaper {
url?: string; url?: string;
base64?: string; base64?: string;
} }
export interface Config {
title: string;
subtitle: string;
backgroundUrls: string[];
wallpaperFrequency: string;
wallpaperBlur: number;
wallpaperBrightness: number;
wallpaperOpacity: number;
titleSize: string;
subtitleSize: string;
alignment: string;
horizontalAlignment: string;
clock: {
enabled: boolean;
size: string;
font: string;
format: string;
};
serverWidget: {
enabled: boolean;
pingFrequency: number;
servers: Server[];
};
tileSize?: string;
}