Compare commits
5
Commits
e08853fe54
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41eb568610 | ||
|
|
551bff1e5b | ||
|
|
d391dc7135 | ||
|
|
4885fd68c1 | ||
|
|
31de8265d9 |
@@ -39,20 +39,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare release
|
- name: Prepare release
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p vision-start
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
mv extension vision-start/
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
|
- name: Set archive name
|
||||||
|
run: |
|
||||||
|
SAFE_REF="${GITEA_REF_NAME//\//-}"
|
||||||
|
ARCHIVE="vision-start-${SAFE_REF}.zip"
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: |
|
run: |
|
||||||
cd vision-start
|
cd vision-start
|
||||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
zip -r "../${ARCHIVE_NAME}" *
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
path: vision-start-${{ gitea.ref_name }}.zip
|
path: ${{ env.ARCHIVE_NAME }}
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare archive
|
- name: Prepare archive
|
||||||
run: |
|
run: |
|
||||||
mkdir vision-start
|
mkdir -p vision-start
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
mv extension vision-start/
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
zip-file: vision-start-${{ gitea.ref_name }}.zip
|
zip-file: ${{ steps.set-archive.outputs.archive-name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Setup required tools
|
- name: Setup required tools
|
||||||
run: sudo apt-get install zip jq curl -y
|
run: sudo apt-get install zip unzip jq curl -y
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -48,22 +48,66 @@ jobs:
|
|||||||
bash scripts/prepare_release.sh
|
bash scripts/prepare_release.sh
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Prepare release
|
- name: Prepare Chrome Web Store package
|
||||||
run: |
|
run: |
|
||||||
mv dist vision-start/
|
mkdir vision-start
|
||||||
mv extension vision-start/
|
cp -a dist/. vision-start/
|
||||||
mv manifest.json vision-start/
|
cp -a extension vision-start/
|
||||||
|
cp manifest.json vision-start/
|
||||||
|
|
||||||
|
- name: Set archive name
|
||||||
|
id: set-archive
|
||||||
|
run: |
|
||||||
|
SAFE_REF="${GITEA_REF_NAME//\//-}"
|
||||||
|
ARCHIVE="vision-start-${SAFE_REF}.zip"
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi
|
||||||
|
if [ -n "${GITEA_OUTPUT:-}" ]; then echo "archive-name=${ARCHIVE}" >> "$GITEA_OUTPUT"; fi
|
||||||
|
if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "archive-name=${ARCHIVE}" >> "$GITHUB_OUTPUT"; fi
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: |
|
run: |
|
||||||
cd vision-start
|
cd vision-start
|
||||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
zip -r -X "../${ARCHIVE_NAME}" .
|
||||||
|
|
||||||
|
- name: Validate Chrome Web Store archive
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
unzip -t "$ARCHIVE_NAME"
|
||||||
|
PACKAGE_DIR=$(mktemp -d)
|
||||||
|
unzip -q "$ARCHIVE_NAME" -d "$PACKAGE_DIR"
|
||||||
|
node --input-type=module - "$PACKAGE_DIR" "${RELEASE_TAG#v}" <<'NODE'
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import { resolve, sep } from 'node:path';
|
||||||
|
const [directory, version] = process.argv.slice(2);
|
||||||
|
const manifest = JSON.parse(readFileSync(resolve(directory, 'manifest.json'), 'utf8'));
|
||||||
|
assert.equal(manifest.manifest_version, 3);
|
||||||
|
assert.equal(manifest.version, version);
|
||||||
|
assert.match(version, /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/);
|
||||||
|
const parts = version.split('.').map(Number);
|
||||||
|
assert(parts.every(part => part <= 65535) && parts.some(part => part > 0), 'Invalid Chrome extension version');
|
||||||
|
assert(typeof manifest.name === 'string' && manifest.name.length > 0);
|
||||||
|
assert(typeof manifest.description === 'string' && manifest.description.length <= 132);
|
||||||
|
assert.equal(manifest.chrome_url_overrides.newtab, 'index.html');
|
||||||
|
assert(manifest.icons['128'], 'Missing store icon');
|
||||||
|
for (const file of [manifest.chrome_url_overrides.newtab, ...Object.values(manifest.icons)]) {
|
||||||
|
const path = resolve(directory, file);
|
||||||
|
assert(path.startsWith(resolve(directory) + sep), `Invalid package path: ${file}`);
|
||||||
|
assert(statSync(path).isFile(), `Missing packaged file: ${file}`);
|
||||||
|
}
|
||||||
|
console.log('Chrome Web Store archive structure and manifest validated');
|
||||||
|
NODE
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
path: vision-start-${{ gitea.ref_name }}.zip
|
path: ${{ env.ARCHIVE_NAME }}
|
||||||
|
|
||||||
virus-total-check:
|
virus-total-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -84,7 +128,7 @@ jobs:
|
|||||||
id: vt-check
|
id: vt-check
|
||||||
env:
|
env:
|
||||||
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
||||||
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
VIRUS_TOTAL_FILE: ${{ needs.build.outputs.zip-file }}
|
||||||
run: |
|
run: |
|
||||||
# Run the VirusTotal check script and capture output in real-time
|
# Run the VirusTotal check script and capture output in real-time
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
@@ -124,7 +168,7 @@ jobs:
|
|||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
tag_name: ${{ gitea.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
vision-start-${{ gitea.ref_name }}.zip
|
${{ needs.build.outputs.zip-file }}
|
||||||
release-screenshots/home.png
|
release-screenshots/home.png
|
||||||
release-screenshots/editing.png
|
release-screenshots/editing.png
|
||||||
release-screenshots/configuration.png
|
release-screenshots/configuration.png
|
||||||
|
|||||||
@@ -8,43 +8,14 @@ import ConfigurationButton from './components/layout/ConfigurationButton';
|
|||||||
import CategoryGroup from './components/layout/CategoryGroup';
|
import CategoryGroup from './components/layout/CategoryGroup';
|
||||||
import Wallpaper from './components/Wallpaper';
|
import Wallpaper from './components/Wallpaper';
|
||||||
import { ConfigurationService } from './components/services/ConfigurationService';
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
|
import { getAlignmentClass } from './components/utils/styleUtils';
|
||||||
|
import { getRandomWallpaperIndex, loadWallpaperState, saveWallpaperState } from './components/utils/wallpaperUtils';
|
||||||
|
import { PlusIcon } from './components/icons';
|
||||||
|
|
||||||
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||||
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||||
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||||
|
|
||||||
const getAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'top':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'bottom':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHorizontalAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'left':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'right':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
|
||||||
if (wallpaperCount <= 1) return 0;
|
|
||||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
|
||||||
return (currentIndex + offset) % wallpaperCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<Category[]>(() => {
|
const [categories, setCategories] = useState<Category[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -91,17 +62,9 @@ const App: React.FC = () => {
|
|||||||
const names = config.currentWallpapers;
|
const names = config.currentWallpapers;
|
||||||
if (names.length === 0) return;
|
if (names.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
const { currentIndex } = loadWallpaperState(names);
|
||||||
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
|
const randomIndex = getRandomWallpaperIndex(names.length, currentIndex);
|
||||||
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
|
saveWallpaperState(names, randomIndex, Date.now());
|
||||||
const randomIndex = getRandomWallpaperIndex(names.length, safeCurrent);
|
|
||||||
localStorage.setItem(
|
|
||||||
'wallpaperState',
|
|
||||||
JSON.stringify({
|
|
||||||
lastWallpaperChange: new Date().toISOString(),
|
|
||||||
currentIndex: randomIndex,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error randomizing wallpaper state', error);
|
console.error('Error randomizing wallpaper state', error);
|
||||||
}
|
}
|
||||||
@@ -224,13 +187,12 @@ const App: React.FC = () => {
|
|||||||
setAddingWebsite={setAddingWebsite}
|
setAddingWebsite={setAddingWebsite}
|
||||||
setEditingWebsite={setEditingWebsite}
|
setEditingWebsite={setEditingWebsite}
|
||||||
handleMoveWebsite={handleMoveWebsite}
|
handleMoveWebsite={handleMoveWebsite}
|
||||||
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
|
||||||
horizontalAlignment={config.horizontalAlignment}
|
horizontalAlignment={config.horizontalAlignment}
|
||||||
tileSize={config.tileSize}
|
tileSize={config.tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
|
<div className="flex justify-center transition-all duration-200 ease-ios transform scale-100 opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
@@ -239,10 +201,7 @@ const App: React.FC = () => {
|
|||||||
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
||||||
aria-label="Add category"
|
aria-label="Add category"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PlusIcon size={22} />
|
||||||
<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>
|
|
||||||
Add category
|
Add category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Category } from '../types';
|
import { Category } from '../types';
|
||||||
|
import ModalShell from './ModalShell';
|
||||||
|
|
||||||
interface CategoryEditModalProps {
|
interface CategoryEditModalProps {
|
||||||
category?: Category;
|
category?: Category;
|
||||||
@@ -12,17 +13,14 @@ interface CategoryEditModalProps {
|
|||||||
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(category ? category.name : '');
|
const [name, setName] = useState(category ? category.name : '');
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
<ModalShell
|
||||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
title={edit ? 'Edit Category' : 'Add Category'}
|
||||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
edit={edit}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={onClose}
|
||||||
|
onSave={() => onSave(name)}
|
||||||
|
onDelete={edit ? onDelete : undefined}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Category Name"
|
placeholder="Category Name"
|
||||||
@@ -30,26 +28,7 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="liquid-input p-3"
|
className="liquid-input p-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ModalShell>
|
||||||
<div className="flex justify-between items-center mt-8">
|
|
||||||
<div>
|
|
||||||
{edit && (
|
|
||||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { getClockSizeClass } from './utils/styleUtils';
|
||||||
|
|
||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
config: {
|
config: {
|
||||||
@@ -9,10 +10,9 @@ interface ClockProps {
|
|||||||
format: string;
|
format: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getClockSizeClass: (size: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||||
const [time, setTime] = useState(new Date());
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const importInputRef = useRef<HTMLInputElement>(null);
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isSaving = useRef(false);
|
const isSaving = useRef(false);
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
||||||
ConfigurationService.resetWallpaperState();
|
|
||||||
}, [config.currentWallpapers]);
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -64,8 +62,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
setConfig((prev) => ({ ...prev, ...updates }));
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaper = async (name: string, url: string) => {
|
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||||
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
const newWallpaper = await promise;
|
||||||
const updated = [...userWallpapers, newWallpaper];
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
setUserWallpapers(updated);
|
setUserWallpapers(updated);
|
||||||
ConfigurationService.saveUserWallpapers(updated);
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
@@ -75,16 +73,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaperFile = async (file: File) => {
|
const handleAddWallpaper = (name: string, url: string) =>
|
||||||
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||||
const updated = [...userWallpapers, newWallpaper];
|
|
||||||
setUserWallpapers(updated);
|
const handleAddWallpaperFile = (file: File) =>
|
||||||
ConfigurationService.saveUserWallpapers(updated);
|
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||||
try {
|
try {
|
||||||
@@ -140,7 +133,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
|
||||||
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
||||||
isVisible ? 'translate-x-0' : 'translate-x-full'
|
isVisible ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ModalShellProps {
|
||||||
|
title: string;
|
||||||
|
edit: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||||
|
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||||
|
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
|
||||||
|
<div className="flex flex-col gap-4">{children}</div>
|
||||||
|
<div className="flex justify-between items-center mt-8">
|
||||||
|
<div>
|
||||||
|
{edit && onDelete && (
|
||||||
|
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalShell;
|
||||||
@@ -12,17 +12,14 @@ interface ServerWidgetProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
switch (status) {
|
online: 'bg-green-400 text-green-400',
|
||||||
case 'online':
|
offline: 'bg-red-400 text-red-400',
|
||||||
return 'bg-green-400 text-green-400';
|
|
||||||
case 'offline':
|
|
||||||
return 'bg-red-400 text-red-400';
|
|
||||||
default:
|
|
||||||
return 'bg-slate-400 text-slate-400';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string =>
|
||||||
|
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
|
||||||
|
|
||||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||||
const serversRef = useRef(config.serverWidget.servers);
|
const serversRef = useRef(config.serverWidget.servers);
|
||||||
|
|||||||
+57
-59
@@ -1,8 +1,8 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { Wallpaper as WallpaperType } from '../types';
|
import { Wallpaper as WallpaperType } from '../types';
|
||||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||||
|
import { getRandomWallpaperIndex, getWallpaperFrequencyMs, loadWallpaperState, saveWallpaperState } from './utils/wallpaperUtils';
|
||||||
|
|
||||||
interface WallpaperProps {
|
interface WallpaperProps {
|
||||||
wallpaperNames: string[];
|
wallpaperNames: string[];
|
||||||
@@ -13,28 +13,18 @@ interface WallpaperProps {
|
|||||||
wallpaperVersion: number;
|
wallpaperVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
|
const MAX_WALLPAPER_URL_CACHE = 3;
|
||||||
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
|
|
||||||
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const parseFrequencyToMs = (freq: string): number => {
|
|
||||||
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
|
||||||
const match = freq.match(/^(\d+)(h|d)$/);
|
|
||||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
|
||||||
const value = parseInt(match[1], 10);
|
|
||||||
const unit = match[2];
|
|
||||||
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
|
||||||
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
|
||||||
if (wallpaperCount <= 1) return 0;
|
|
||||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
|
||||||
return (currentIndex + offset) % wallpaperCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||||
|
wallpaperUrlCache.set(name, resolved);
|
||||||
|
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||||
|
const oldest = wallpaperUrlCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
wallpaperUrlCache.delete(oldest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
if (!name) return undefined;
|
if (!name) return undefined;
|
||||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||||
@@ -65,48 +55,40 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wallpaperUrlCache.set(name, resolved);
|
rememberWallpaperUrl(name, resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
const resolvedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let revision = 0;
|
||||||
|
wallpaperUrlCache.clear();
|
||||||
|
|
||||||
const updateWallpaper = async () => {
|
const updateWallpaper = async () => {
|
||||||
|
const request = ++revision;
|
||||||
|
clearTimeout(timer);
|
||||||
if (wallpaperNames.length === 0) {
|
if (wallpaperNames.length === 0) {
|
||||||
setImageUrl(undefined);
|
setImageUrl(undefined);
|
||||||
localStorage.setItem(
|
saveWallpaperState([], 0, loadWallpaperState([]).lastChange);
|
||||||
'wallpaperState',
|
|
||||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
|
||||||
const lastChange = wallpaperState.lastWallpaperChange
|
|
||||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
|
||||||
: 0;
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
const { currentIndex, lastChange } = loadWallpaperState(wallpaperNames, now);
|
||||||
|
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
|
||||||
let storedIndex =
|
const shouldRotate = wallpaperNames.length > 1 && now - lastChange >= freqMs;
|
||||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
|
||||||
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
|
|
||||||
|
|
||||||
const shouldRotate = now - lastChange >= freqMs;
|
|
||||||
let resolvedIndex = shouldRotate
|
let resolvedIndex = shouldRotate
|
||||||
? getRandomWallpaperIndex(wallpaperNames.length, storedIndex)
|
? getRandomWallpaperIndex(wallpaperNames.length, currentIndex)
|
||||||
: storedIndex;
|
: currentIndex;
|
||||||
|
|
||||||
const tried = new Set<number>();
|
|
||||||
let resolvedUrl: string | undefined;
|
let resolvedUrl: string | undefined;
|
||||||
|
|
||||||
for (let i = 0; i < wallpaperNames.length; i++) {
|
for (let i = 0; i < wallpaperNames.length; i++) {
|
||||||
if (tried.has(resolvedIndex)) break;
|
|
||||||
tried.add(resolvedIndex);
|
|
||||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||||
|
if (cancelled || request !== revision) return;
|
||||||
if (url) {
|
if (url) {
|
||||||
resolvedUrl = url;
|
resolvedUrl = url;
|
||||||
break;
|
break;
|
||||||
@@ -114,22 +96,38 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextLastChange = shouldRotate
|
if (cancelled || request !== revision) return;
|
||||||
? new Date().toISOString()
|
const nextLastChange = shouldRotate || resolvedIndex !== currentIndex ? Date.now() : lastChange;
|
||||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
saveWallpaperState(wallpaperNames, resolvedIndex, nextLastChange);
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
'wallpaperState',
|
|
||||||
JSON.stringify({
|
|
||||||
lastWallpaperChange: nextLastChange,
|
|
||||||
currentIndex: resolvedIndex,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
resolvedRef.current = true;
|
|
||||||
setImageUrl(resolvedUrl);
|
setImageUrl(resolvedUrl);
|
||||||
|
if (wallpaperNames.length > 1) {
|
||||||
|
timer = setTimeout(refresh, Math.max(1, nextLastChange + freqMs - Date.now()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const refresh = () => {
|
||||||
|
void updateWallpaper().catch(error => console.error('Error updating wallpaper', error));
|
||||||
|
};
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') refresh();
|
||||||
|
};
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === 'wallpaperState' || event.key === 'userWallpapers' || event.key === null) {
|
||||||
|
wallpaperUrlCache.clear();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.addEventListener('focus', refresh);
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.removeEventListener('focus', refresh);
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
};
|
};
|
||||||
updateWallpaper();
|
|
||||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||||
|
|
||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import { getWebsiteIcon } from './utils/iconService';
|
import { getWebsiteIcon } from './utils/iconService';
|
||||||
|
import ModalShell from './ModalShell';
|
||||||
|
|
||||||
interface WebsiteEditModalProps {
|
interface WebsiteEditModalProps {
|
||||||
website?: Website;
|
website?: Website;
|
||||||
@@ -27,6 +28,9 @@ interface IconMetadata {
|
|||||||
|
|
||||||
let iconMetadataCache: IconMetadata[] | null = null;
|
let iconMetadataCache: IconMetadata[] | null = null;
|
||||||
|
|
||||||
|
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||||
|
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||||
|
|
||||||
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 : '');
|
||||||
@@ -37,6 +41,12 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||||
const debounceRef = useRef<number | null>(null);
|
const debounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
iconMetadataCache = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const ensureIconMetadata = () => {
|
const ensureIconMetadata = () => {
|
||||||
if (iconMetadataCache) {
|
if (iconMetadataCache) {
|
||||||
setIconMetadata(iconMetadataCache);
|
setIconMetadata(iconMetadataCache);
|
||||||
@@ -68,10 +78,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
filtered.push(ic);
|
filtered.push(ic);
|
||||||
if (filtered.length >= 50) break;
|
if (filtered.length >= 50) break;
|
||||||
}
|
}
|
||||||
if (ic.colors) {
|
if (ic.colors && typeof ic.colors === 'object') {
|
||||||
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
|
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||||
for (const color of colors) {
|
for (const color of colors as string[]) {
|
||||||
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
|
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||||
filtered.push({ ...ic, name: color });
|
filtered.push({ ...ic, name: color });
|
||||||
if (filtered.length >= 50) break;
|
if (filtered.length >= 50) break;
|
||||||
}
|
}
|
||||||
@@ -96,21 +106,14 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave({ id: website?.id, name, url, icon });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
<ModalShell
|
||||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
title={edit ? 'Edit Website' : 'Add Website'}
|
||||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
edit={edit}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={onClose}
|
||||||
|
onSave={() => onSave({ id: website?.id, name, url, icon })}
|
||||||
|
onDelete={edit ? onDelete : undefined}
|
||||||
|
>
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<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" />
|
||||||
@@ -153,18 +156,17 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
/>
|
/>
|
||||||
{filteredIcons.length > 0 && (
|
{filteredIcons.length > 0 && (
|
||||||
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
||||||
{filteredIcons.map(iconData => (
|
{filteredIcons.map((iconData, index) => (
|
||||||
<div
|
<div
|
||||||
key={iconData.name}
|
key={`${iconData.name}-${index}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
setIcon(getIconPickUrl(iconData));
|
||||||
setIcon(iconUrl);
|
|
||||||
setFilteredIcons([]);
|
setFilteredIcons([]);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
src={getIconPickUrl(iconData)}
|
||||||
alt={iconData.name}
|
alt={iconData.name}
|
||||||
className="h-6 w-6 mr-2"
|
className="h-6 w-6 mr-2"
|
||||||
/>
|
/>
|
||||||
@@ -178,26 +180,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
Fetch
|
Fetch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalShell>
|
||||||
<div className="flex justify-between items-center mt-8">
|
|
||||||
<div>
|
|
||||||
{edit && (
|
|
||||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button onClick={handleSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo, useEffect, useState } from 'react';
|
import React, { memo, useEffect, useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
||||||
|
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
website: Website;
|
website: Website;
|
||||||
@@ -10,46 +12,6 @@ interface WebsiteTileProps {
|
|||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTileSizeClass = (size: string | undefined) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const getIconPixelSize = (size: string | undefined): number => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 34;
|
|
||||||
case 'medium':
|
|
||||||
return 42;
|
|
||||||
case 'large':
|
|
||||||
return 48;
|
|
||||||
default:
|
|
||||||
return 40;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -141,15 +103,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
</a>
|
</a>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="liquid-surface liquid-edit-toolbar">
|
<div className="liquid-surface liquid-edit-toolbar">
|
||||||
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
|
||||||
<path fillRule="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={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
|
||||||
</svg></button>
|
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></button>
|
||||||
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<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="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path fillRule="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>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface ClockTabProps {
|
interface ClockTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
name="clock.size"
|
name="clock.size"
|
||||||
value={config.clock.size}
|
value={config.clock.size}
|
||||||
onChange={(e) => updateClock({ size: e.target.value as string })}
|
onChange={(e) => updateClock({ size: e.target.value as string })}
|
||||||
options={[
|
options={SIZE_OPTIONS}
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
name="titleSize"
|
name="titleSize"
|
||||||
value={config.titleSize}
|
value={config.titleSize}
|
||||||
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
||||||
options={[
|
options={SIZE_OPTIONS}
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||||
|
|
||||||
|
export const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||||
|
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||||
|
return { '--range-progress': `${progress}%` };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RangeSliderProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
valueSuffix?: string;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RangeSlider: React.FC<RangeSliderProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
valueSuffix,
|
||||||
|
formatValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">{label}</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(value, min, max)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-20 text-right text-sm text-slate-200">
|
||||||
|
{formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RangeSlider;
|
||||||
@@ -2,19 +2,14 @@ import React, { useState } from 'react';
|
|||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import { Config, Server } from '../../types';
|
import { Config, Server } from '../../types';
|
||||||
|
import RangeSlider from './RangeSlider';
|
||||||
|
import { TrashIcon } from '../icons';
|
||||||
|
|
||||||
interface ServerWidgetTabProps {
|
interface ServerWidgetTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
onChange: (updates: Partial<Config>) => void;
|
onChange: (updates: Partial<Config>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
|
||||||
|
|
||||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
|
||||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
||||||
return { '--range-progress': `${progress}%` };
|
|
||||||
};
|
|
||||||
|
|
||||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
const [newServerName, setNewServerName] = useState('');
|
const [newServerName, setNewServerName] = useState('');
|
||||||
const [newServerAddress, setNewServerAddress] = useState('');
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
@@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
{config.serverWidget.enabled && (
|
{config.serverWidget.enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
label="Ping Frequency"
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="5"
|
|
||||||
max="60"
|
|
||||||
value={config.serverWidget.pingFrequency}
|
value={config.serverWidget.pingFrequency}
|
||||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
min={5}
|
||||||
className="liquid-range"
|
max={60}
|
||||||
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
valueSuffix="s"
|
||||||
|
onChange={(value) => updateServerWidget({ pingFrequency: value })}
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
@@ -103,20 +91,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
aria-label={`Remove ${server.name}`}
|
aria-label={`Remove ${server.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<TrashIcon size={16} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import { Config, Wallpaper } from '../../types';
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
import RangeSlider from './RangeSlider';
|
||||||
|
import { TrashIcon } from '../icons';
|
||||||
|
import {
|
||||||
|
getWallpaperFrequencyHours,
|
||||||
|
formatWallpaperFrequency,
|
||||||
|
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
} from '../utils/wallpaperUtils';
|
||||||
|
|
||||||
interface ThemeTabProps {
|
interface ThemeTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -14,31 +22,6 @@ interface ThemeTabProps {
|
|||||||
onRandomWallpaper: () => void;
|
onRandomWallpaper: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
|
||||||
|
|
||||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
|
||||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
||||||
return { '--range-progress': `${progress}%` };
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
|
||||||
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
|
||||||
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
|
||||||
|
|
||||||
const clampWallpaperFrequencyHours = (hours: number): number =>
|
|
||||||
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
|
|
||||||
|
|
||||||
const getWallpaperFrequencyHours = (frequency: string): number => {
|
|
||||||
const match = frequency.match(/^(\d+)(h|d)$/);
|
|
||||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
|
||||||
const value = Number(match[1]);
|
|
||||||
const hours = match[2] === 'd' ? value * 24 : value;
|
|
||||||
return clampWallpaperFrequencyHours(hours);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatWallpaperFrequency = (hours: number): string =>
|
|
||||||
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
||||||
|
|
||||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -52,7 +35,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||||
|
|
||||||
const handleAddWallpaper = async () => {
|
const handleAddWallpaper = async () => {
|
||||||
@@ -76,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await onAddWallpaperFile(file);
|
await onAddWallpaperFile(file);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -96,74 +78,39 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
label="Change Frequency"
|
||||||
<div className="flex items-center gap-4">
|
value={wallpaperFrequencyHours}
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||||
step="1"
|
formatValue={formatWallpaperFrequency}
|
||||||
value={wallpaperFrequencyHours}
|
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
|
||||||
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(
|
|
||||||
wallpaperFrequencyHours,
|
|
||||||
MIN_WALLPAPER_FREQUENCY_HOURS,
|
|
||||||
MAX_WALLPAPER_FREQUENCY_HOURS,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<span className="w-20 text-right text-sm text-slate-200">
|
|
||||||
{formatWallpaperFrequency(wallpaperFrequencyHours)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
label="Wallpaper Blur"
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="50"
|
|
||||||
value={config.wallpaperBlur}
|
value={config.wallpaperBlur}
|
||||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
min={0}
|
||||||
className="liquid-range"
|
max={50}
|
||||||
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
valueSuffix="px"
|
||||||
|
onChange={(value) => onChange({ wallpaperBlur: value })}
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
<RangeSlider
|
||||||
</div>
|
label="Wallpaper Brightness"
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="200"
|
|
||||||
value={config.wallpaperBrightness}
|
value={config.wallpaperBrightness}
|
||||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
min={0}
|
||||||
className="liquid-range"
|
max={200}
|
||||||
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
valueSuffix="%"
|
||||||
|
onChange={(value) => onChange({ wallpaperBrightness: value })}
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
<RangeSlider
|
||||||
</div>
|
label="Wallpaper Opacity"
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.wallpaperOpacity}
|
value={config.wallpaperOpacity}
|
||||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
min={1}
|
||||||
className="liquid-range"
|
max={100}
|
||||||
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
valueSuffix="%"
|
||||||
|
onChange={(value) => onChange({ wallpaperOpacity: value })}
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -178,20 +125,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
aria-label={`Delete ${wallpaper.name}`}
|
aria-label={`Delete ${wallpaper.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<TrashIcon size={16} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -254,7 +188,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
ref={fileInputRef}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PencilIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlusIcon: React.FC<IconProps> = ({ size = 22, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon: React.FC<IconProps> = ({ size = 16, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronLeftIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path fillRule="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" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path fillRule="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>
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import WebsiteTile from '../WebsiteTile';
|
import WebsiteTile from '../WebsiteTile';
|
||||||
import { Category, Website } from '../../types';
|
import { Category, Website } from '../../types';
|
||||||
|
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
|
||||||
|
import { PencilIcon, PlusIcon } from '../icons';
|
||||||
|
|
||||||
interface CategoryGroupProps {
|
interface CategoryGroupProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
|
|||||||
setAddingWebsite: (category: Category) => void;
|
setAddingWebsite: (category: Category) => void;
|
||||||
setEditingWebsite: (website: Website) => void;
|
setEditingWebsite: (website: Website) => void;
|
||||||
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
||||||
getHorizontalAlignmentClass: (alignment: string) => string;
|
|
||||||
horizontalAlignment: string;
|
horizontalAlignment: string;
|
||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAddTileSizeClass = (size: string | undefined) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
category,
|
category,
|
||||||
isEditing,
|
isEditing,
|
||||||
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
setAddingWebsite,
|
setAddingWebsite,
|
||||||
setEditingWebsite,
|
setEditingWebsite,
|
||||||
handleMoveWebsite,
|
handleMoveWebsite,
|
||||||
getHorizontalAlignmentClass,
|
|
||||||
horizontalAlignment,
|
horizontalAlignment,
|
||||||
tileSize,
|
tileSize,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="w-full">
|
<div key={category.id} className="w-full">
|
||||||
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
<div className={`flex ${getAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||||
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
@@ -53,13 +40,11 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
aria-label={`Edit ${category.name} category`}
|
aria-label={`Edit ${category.name} category`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PencilIcon size={14} />
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
<div className={`flex flex-wrap ${getAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||||
{category.websites.map((website) => (
|
{category.websites.map((website) => (
|
||||||
<WebsiteTile
|
<WebsiteTile
|
||||||
key={website.id}
|
key={website.id}
|
||||||
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddingWebsite(category)}
|
onClick={() => setAddingWebsite(category)}
|
||||||
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getAddTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
aria-label={`Add website to ${category.name}`}
|
aria-label={`Add website to ${category.name}`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PlusIcon size={28} />
|
||||||
<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>
|
|
||||||
<span className="text-sm font-bold">Add</span>
|
<span className="text-sm font-bold">Add</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
import { PencilIcon } from '../icons';
|
||||||
|
|
||||||
interface EditButtonProps {
|
interface EditButtonProps {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
@@ -13,9 +13,7 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
|||||||
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||||
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PencilIcon size={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' : ''}
|
{isEditing ? 'Done' : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,17 @@
|
|||||||
import Clock from '../Clock';
|
import Clock from '../Clock';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { getTitleSizeClass } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
config: Config;
|
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';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{config.clock.enabled && (
|
{config.clock.enabled && (
|
||||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
||||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
<Clock config={config} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
checkChromeStorageLocalAvailable,
|
checkChromeStorageLocalAvailable,
|
||||||
removeWallpaperFromChromeStorageLocal,
|
removeWallpaperFromChromeStorageLocal,
|
||||||
} from '../utils/StorageLocalManager';
|
} from '../utils/StorageLocalManager';
|
||||||
|
import { getFileNameFromUrl } from '../utils/urlUtils';
|
||||||
|
|
||||||
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||||
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||||
@@ -44,16 +45,6 @@ const safeParse = (value: string | null): unknown => {
|
|||||||
const toStorageString = (value: unknown): string =>
|
const toStorageString = (value: unknown): string =>
|
||||||
typeof value === 'string' ? value : JSON.stringify(value);
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
const getWallpaperNameFromUrl = (url: URL): string => {
|
|
||||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
|
||||||
if (!pathName) return url.hostname;
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(pathName);
|
|
||||||
} catch {
|
|
||||||
return pathName;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
@@ -121,7 +112,7 @@ export const ConfigurationService = {
|
|||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
}
|
}
|
||||||
const finalName = name.trim() || getWallpaperNameFromUrl(parsedUrl) || 'Wallpaper';
|
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||||
return { name: finalName, url: parsedUrl.href };
|
return { name: finalName, url: parsedUrl.href };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -214,14 +205,4 @@ export const ConfigurationService = {
|
|||||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
resetWallpaperState(): void {
|
|
||||||
localStorage.setItem(
|
|
||||||
'wallpaperState',
|
|
||||||
JSON.stringify({
|
|
||||||
lastWallpaperChange: new Date().toISOString(),
|
|
||||||
currentIndex: 0,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// TypeScript interface for window.chrome
|
// TypeScript interface for window.chrome
|
||||||
|
import { getFileNameFromUrl } from './urlUtils';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
chrome?: {
|
chrome?: {
|
||||||
@@ -23,7 +25,6 @@ const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
|
|||||||
const getIconCacheKey = (sourceUrl: string): string =>
|
const getIconCacheKey = (sourceUrl: string): string =>
|
||||||
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
|
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if chrome.storage.local is available and caches the result.
|
* Checks if chrome.storage.local is available and caches the result.
|
||||||
*/
|
*/
|
||||||
@@ -37,54 +38,60 @@ export function checkChromeStorageLocalAvailable(): boolean {
|
|||||||
return isChromeStorageLocalAvailable;
|
return isChromeStorageLocalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StorageResult = { [key: string]: string };
|
||||||
|
|
||||||
|
const chromeLocalCall = <T>(
|
||||||
|
operation: (callback: (result: T) => void) => void,
|
||||||
|
): Promise<T> =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
if (!window.chrome?.storage?.local) {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operation((result) => {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||||
if (!checkChromeStorageLocalAvailable()) return null;
|
if (!checkChromeStorageLocalAvailable()) return null;
|
||||||
|
try {
|
||||||
return new Promise<string | null>((resolve) => {
|
|
||||||
if (!window.chrome?.storage?.local) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = getIconCacheKey(sourceUrl);
|
const key = getIconCacheKey(sourceUrl);
|
||||||
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
|
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||||
if (window.chrome?.runtime?.lastError) {
|
window.chrome?.storage?.local?.get([key], cb),
|
||||||
resolve(null);
|
);
|
||||||
return;
|
return result[key] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
resolve(result[key] || null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
||||||
if (!checkChromeStorageLocalAvailable()) return false;
|
if (!checkChromeStorageLocalAvailable()) return false;
|
||||||
|
try {
|
||||||
return new Promise<boolean>((resolve) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (!window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
|
||||||
resolve(false);
|
);
|
||||||
return;
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () {
|
|
||||||
resolve(!window.chrome?.runtime?.lastError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
|
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
|
||||||
if (!checkChromeStorageLocalAvailable()) return false;
|
if (!checkChromeStorageLocalAvailable()) return false;
|
||||||
|
try {
|
||||||
return new Promise<boolean>((resolve) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (!window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
|
||||||
resolve(false);
|
);
|
||||||
return;
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () {
|
|
||||||
resolve(!window.chrome?.runtime?.lastError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,22 +120,13 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
|
|||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
}
|
}
|
||||||
finalName = finalName || parsedUrl.pathname.split('/').filter(Boolean).pop() || parsedUrl.hostname;
|
finalName = finalName || getFileNameFromUrl(parsedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (!window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
);
|
||||||
return;
|
return finalName;
|
||||||
}
|
|
||||||
window.chrome.storage.local.set({ [finalName]: url }, function () {
|
|
||||||
if (window.chrome?.runtime?.lastError) {
|
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).then(() => finalName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,19 +139,10 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
|
|||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
return new Promise<string | null>((resolve, reject) => {
|
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||||
if (window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.get([name], cb),
|
||||||
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
|
);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
return result[name] || null;
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve(result[name] || null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
|
|||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
return new Promise<void>((resolve, reject) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.remove(name, cb),
|
||||||
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'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,19 @@ import {
|
|||||||
} from './StorageLocalManager';
|
} from './StorageLocalManager';
|
||||||
|
|
||||||
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||||
|
const MAX_RESOLVED_ICONS = 50;
|
||||||
const resolvedIconCache = new Map<string, string>();
|
const resolvedIconCache = new Map<string, string>();
|
||||||
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
||||||
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
|
||||||
|
resolvedIconCache.set(iconUrl, dataUrl);
|
||||||
|
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
|
||||||
|
const oldest = resolvedIconCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) resolvedIconCache.delete(oldest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
||||||
|
|
||||||
const isCacheableIconUrl = (value: string): boolean => {
|
const isCacheableIconUrl = (value: string): boolean => {
|
||||||
@@ -38,33 +47,75 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
|
|||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getWebsiteIcon(url: string): Promise<string> {
|
const TRUSTED_TLDS = new Set([
|
||||||
|
'com',
|
||||||
|
'org',
|
||||||
|
'net',
|
||||||
|
'gov',
|
||||||
|
'edu',
|
||||||
|
'io',
|
||||||
|
'co',
|
||||||
|
'dev',
|
||||||
|
'app',
|
||||||
|
'me',
|
||||||
|
'ai',
|
||||||
|
'info',
|
||||||
|
'br',
|
||||||
|
'uk',
|
||||||
|
'de',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isTrustedTld = (hostname: string): boolean => {
|
||||||
|
if (!hostname || !hostname.includes('.')) return false;
|
||||||
|
const parts = hostname.toLowerCase().split('.');
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
return TRUSTED_TLDS.has(lastPart);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconFetchSource = (iconUrl: string): string | null => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const url = new URL(iconUrl);
|
||||||
const html = await response.text();
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
if (
|
||||||
|
(url.hostname === 'www.google.com' || url.hostname === 'google.com') &&
|
||||||
|
url.pathname.startsWith('/s2/favicons')
|
||||||
|
) {
|
||||||
|
const domain = url.searchParams.get('domain');
|
||||||
|
if (domain) {
|
||||||
|
let cleanHost = domain.trim().replace(/^https?:\/\//i, '');
|
||||||
|
try {
|
||||||
|
cleanHost = new URL(`https://${cleanHost}`).hostname;
|
||||||
|
} catch {
|
||||||
|
// ignore parsing error
|
||||||
|
}
|
||||||
|
if (cleanHost && isTrustedTld(cleanHost)) {
|
||||||
|
return `https://icon.horse/icon/${encodeURIComponent(cleanHost)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const appleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]');
|
async function getWebsiteIcon(rawUrl: string): Promise<string> {
|
||||||
if (appleTouchIcon) {
|
const trimmed = rawUrl.trim();
|
||||||
const href = appleTouchIcon.getAttribute('href');
|
const hasProtocol = /^https?:\/\//i.test(trimmed);
|
||||||
if (href) {
|
const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`;
|
||||||
return new URL(href, url).href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconLink = doc.querySelector('link[rel="icon"][type="image/png"]') || doc.querySelector('link[rel="icon"]');
|
try {
|
||||||
if (iconLink) {
|
const parsed = new URL(targetUrl);
|
||||||
const href = iconLink.getAttribute('href');
|
if (!isTrustedTld(parsed.hostname)) {
|
||||||
if (href) {
|
if (!hasProtocol) {
|
||||||
return new URL(href, url).href;
|
return `http://${parsed.host}/favicon.ico`;
|
||||||
}
|
}
|
||||||
|
return `${parsed.origin}/favicon.ico`;
|
||||||
}
|
}
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Error fetching and parsing HTML for icon:', error);
|
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||||
@@ -80,7 +131,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
|||||||
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
|
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
|
||||||
.then((cachedIcon) => {
|
.then((cachedIcon) => {
|
||||||
if (isValidCachedIcon(cachedIcon)) {
|
if (isValidCachedIcon(cachedIcon)) {
|
||||||
resolvedIconCache.set(iconUrl, cachedIcon);
|
rememberResolvedIcon(iconUrl, cachedIcon);
|
||||||
return cachedIcon;
|
return cachedIcon;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -107,8 +158,11 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
|||||||
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
|
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
|
||||||
if (cachedIcon) return cachedIcon;
|
if (cachedIcon) return cachedIcon;
|
||||||
|
|
||||||
|
const fetchSource = getIconFetchSource(iconUrl);
|
||||||
|
if (!fetchSource) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(iconUrl, { mode: 'cors' });
|
const response = await fetch(fetchSource);
|
||||||
if (!response.ok || response.type === 'opaque') return null;
|
if (!response.ok || response.type === 'opaque') return null;
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
@@ -121,7 +175,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataUrl = await blobToDataUrl(blob);
|
const dataUrl = await blobToDataUrl(blob);
|
||||||
resolvedIconCache.set(iconUrl, dataUrl);
|
rememberResolvedIcon(iconUrl, dataUrl);
|
||||||
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const SIZE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'tiny', label: 'Tiny' },
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'large', label: 'Large' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TILE_SIZE_CLASSES: Record<string, string> = {
|
||||||
|
small: 'w-28 h-28',
|
||||||
|
medium: 'w-32 h-32',
|
||||||
|
large: 'w-36 h-36',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTileSizeClass = (size: string | undefined): string =>
|
||||||
|
TILE_SIZE_CLASSES[size ?? ''] ?? 'w-32 h-32';
|
||||||
|
|
||||||
|
export const ICON_PIXEL_SIZES: Record<string, number> = {
|
||||||
|
small: 34,
|
||||||
|
medium: 42,
|
||||||
|
large: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIconPixelSize = (size: string | undefined): number =>
|
||||||
|
ICON_PIXEL_SIZES[size ?? ''] ?? 40;
|
||||||
|
|
||||||
|
export const ICON_LOADING_PIXEL_SIZES: Record<string, number> = {
|
||||||
|
small: 24,
|
||||||
|
medium: 32,
|
||||||
|
large: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIconLoadingPixelSize = (size: string | undefined): number =>
|
||||||
|
ICON_LOADING_PIXEL_SIZES[size ?? ''] ?? 32;
|
||||||
|
|
||||||
|
export const CLOCK_SIZE_CLASSES: Record<string, string> = {
|
||||||
|
tiny: 'text-3xl',
|
||||||
|
small: 'text-4xl',
|
||||||
|
medium: 'text-5xl',
|
||||||
|
large: 'text-6xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClockSizeClass = (size: string): string =>
|
||||||
|
CLOCK_SIZE_CLASSES[size] ?? 'text-5xl';
|
||||||
|
|
||||||
|
export const TITLE_SIZE_CLASSES: Record<string, string> = {
|
||||||
|
tiny: 'text-4xl',
|
||||||
|
small: 'text-5xl',
|
||||||
|
medium: 'text-6xl',
|
||||||
|
large: 'text-7xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTitleSizeClass = (size: string): string =>
|
||||||
|
TITLE_SIZE_CLASSES[size] ?? 'text-6xl';
|
||||||
|
|
||||||
|
export const ALIGNMENT_CLASSES: Record<string, string> = {
|
||||||
|
top: 'justify-start',
|
||||||
|
left: 'justify-start',
|
||||||
|
middle: 'justify-center',
|
||||||
|
bottom: 'justify-end',
|
||||||
|
right: 'justify-end',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAlignmentClass = (alignment: string): string =>
|
||||||
|
ALIGNMENT_CLASSES[alignment] ?? 'justify-center';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const getFileNameFromUrl = (url: URL): string => {
|
||||||
|
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||||
|
if (!pathName) return url.hostname;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(pathName);
|
||||||
|
} catch {
|
||||||
|
return pathName;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||||
|
export const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||||
|
export const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||||
|
|
||||||
|
export const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||||
|
if (!frequency) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||||
|
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||||
|
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const hours = match[2] === 'd' ? value * 24 : value;
|
||||||
|
return Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, hours));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWallpaperFrequencyMs = (frequency: string): number =>
|
||||||
|
getWallpaperFrequencyHours(frequency) * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const formatWallpaperFrequency = (hours: number): string =>
|
||||||
|
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
|
||||||
|
export const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
||||||
|
if (wallpaperCount <= 1) return 0;
|
||||||
|
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
||||||
|
return (currentIndex + offset) % wallpaperCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadWallpaperState = (names: string[], now = Date.now()) => {
|
||||||
|
let state: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||||
|
if (parsed && typeof parsed === 'object') state = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
state = {};
|
||||||
|
}
|
||||||
|
const namedIndex = typeof state.currentName === 'string' ? names.indexOf(state.currentName) : -1;
|
||||||
|
const index = namedIndex >= 0 ? namedIndex : typeof state.currentName === 'string' ? 0 : state.currentIndex;
|
||||||
|
const currentIndex = typeof index === 'number' && Number.isInteger(index) && index >= 0 && index < names.length ? index : 0;
|
||||||
|
const timestamp = typeof state.lastWallpaperChange === 'string' ? Date.parse(state.lastWallpaperChange) : NaN;
|
||||||
|
const lastChange = Number.isFinite(timestamp) && timestamp <= now && timestamp >= 0 ? timestamp : now;
|
||||||
|
return { currentIndex, lastChange };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveWallpaperState = (names: string[], currentIndex: number, lastChange: number): void => {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
currentIndex,
|
||||||
|
currentName: names[currentIndex],
|
||||||
|
lastWallpaperChange: new Date(lastChange).toISOString(),
|
||||||
|
});
|
||||||
|
if (localStorage.getItem('wallpaperState') !== payload) {
|
||||||
|
localStorage.setItem('wallpaperState', payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
+22
-14
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
project_name: Vision Start
|
project_name: Vision Start
|
||||||
date: 2026-08-07
|
date: 2026-08-11
|
||||||
type: general_overview
|
type: general_overview
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,20 +58,22 @@ The startpage is composed of widgets and a configuration panel:
|
|||||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
|
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL. Remote URLs remain lightweight entries in the `userWallpapers` index; uploaded image data is stored in `chrome.storage.local` when available.
|
||||||
- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable.
|
- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched for trusted public TLDs as a Google S2 favicon URL derived from the site's hostname (no HTML fetching — the S2 URL is used purely as an `<img>` source). Non-trusted TLDs (e.g. local TLDs, IP addresses) fetch `${origin}/favicon.ico` directly. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable. Cache population fetches trusted TLDs through a CORS-open favicon service (`icon.horse`) because Google's S2 endpoint 301-redirects without `Access-Control-Allow-Origin`, which would block cross-origin `fetch`.
|
||||||
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
||||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
||||||
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
||||||
|
|
||||||
Performance notes:
|
Performance notes:
|
||||||
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population fetches Google S2 favicon URLs for trusted public TLDs through `https://icon.horse/icon/<host>` (a CORS-open favicon service), while non-trusted TLDs and direct icon URLs render directly in `<img>` tags without triggering background `fetch` calls. It deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
||||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
|
||||||
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||||
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||||
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||||
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||||
- Icon metadata (`/icon-metadata.json`) is module-level cached and hydrated into each icon-picker instance, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. The module-level metadata cache is released when the picker modal unmounts (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||||
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
|
- `Wallpaper` resolves wallpaper URLs through a module-level `Map` bounded to the last 3 entries — so uploaded base64 wallpapers (up to ~4.5MB strings) are not retained in memory for the page's lifetime; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||||
|
- The website icon service keeps an in-memory resolved data-URL cache bounded to 50 entries (FIFO eviction) to avoid unbounded growth from many tiles.
|
||||||
|
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
|
||||||
|
|
||||||
Planned / To-do (tracked in `README.md`):
|
Planned / To-do (tracked in `README.md`):
|
||||||
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
@@ -98,9 +100,11 @@ vision-start/
|
|||||||
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||||
│ ├── CategoryEditModal.tsx # Add/edit a category
|
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||||
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||||
|
│ ├── ModalShell.tsx # Shared centered-modal shell (backdrop, title, footer buttons)
|
||||||
│ ├── ServerWidget.tsx # Bottom server status pill
|
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||||
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||||
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
||||||
|
│ ├── icons.tsx # Shared inline SVG icons (pencil, plus, trash, chevrons)
|
||||||
│ │
|
│ │
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ ├── Header.tsx # Renders Clock + Title
|
│ │ ├── Header.tsx # Renders Clock + Title
|
||||||
@@ -112,7 +116,8 @@ vision-start/
|
|||||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||||
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
||||||
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||||
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
│ │ ├── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||||
|
│ │ └── RangeSlider.tsx # Shared labeled range slider (with progress fill)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
||||||
@@ -121,7 +126,10 @@ vision-start/
|
|||||||
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
||||||
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
||||||
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
||||||
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
│ ├── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
||||||
|
│ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets
|
||||||
|
│ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers
|
||||||
|
│ └── urlUtils.ts # URL → filename extraction (shared by wallpaper storage paths)
|
||||||
│
|
│
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── favicon.ico
|
│ ├── favicon.ico
|
||||||
@@ -175,7 +183,7 @@ Storage layout (browser-side):
|
|||||||
| `config` | `localStorage` | The full `Config` JSON |
|
| `config` | `localStorage` | The full `Config` JSON |
|
||||||
| `categories` | `localStorage` | `Category[]` JSON |
|
| `categories` | `localStorage` | `Category[]` JSON |
|
||||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
|
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
|
||||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex, currentName? }` for rotation (legacy index-only state remains supported) |
|
||||||
| `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers |
|
| `<wallpaperName>` | `chrome.storage.local` (when available) | Base64 image data for uploaded wallpaper files and legacy URL wallpapers |
|
||||||
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
|
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
|
||||||
|
|
||||||
@@ -217,7 +225,7 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked"
|
|||||||
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
||||||
|
|
||||||
### CI/CD (Gitea Actions)
|
### CI/CD (Gitea Actions)
|
||||||
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image.
|
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image. The release ZIP contains the contents of `dist/` at its root alongside `manifest.json` and `extension/icons/`, so the manifest's `index.html` new-tab path resolves directly. Before upload, the workflow tests ZIP integrity, extracts it, and validates the manifest version (no leading zeros, components at most 65535, not all zero), metadata, new-tab page, and icon paths. This is the Chrome Web Store upload ZIP; screenshots remain separate release assets.
|
||||||
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
||||||
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
|
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
|
||||||
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
||||||
@@ -232,12 +240,12 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
## 8. Notable Behaviors & Quirks
|
## 8. Notable Behaviors & Quirks
|
||||||
|
|
||||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, both of which share the `ModalShell` component.
|
||||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
||||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **Wallpaper rotation** uses a timeout scheduled from the persisted last-change timestamp, with checks on mount, focus, and return to a visible tab. Overdue backgrounds rotate once and start a new interval; frequency changes use elapsed time rather than restarting the countdown. Frequency is clamped to 1–48 hours and legacy `1d`/`2d` values remain supported. Invalid or future timestamps recover to the current time. State tracks the wallpaper name as well as its legacy index so reordering or shrinking the selection preserves the current wallpaper when possible. Missing data is skipped and an empty selection hides the background. Manual random changes exclude the current wallpaper and restart the interval. Opening settings does not reset rotation. Wallpaper state writes skip identical serialized values, and empty selections preserve the saved timestamp during refreshes to prevent repeated cross-tab updates. Storage events synchronize wallpaper changes across tabs; outdated asynchronous resolutions are discarded. URL caches are cleared when wallpaper inputs or stored wallpaper data change.
|
||||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||||
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only CORS-readable `http`/`https` image responses no larger than 256KiB are cached.
|
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `image/*` responses no larger than 256KiB are cached. Trusted public TLDs use Google S2 and the CORS-open `icon.horse` service for favicon caching, while non-trusted TLDs (local TLDs, IP addresses, custom domains) fetch `${origin}/favicon.ico` directly. No site HTML is ever fetched for icon discovery.
|
||||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||||
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||||
@@ -266,4 +274,4 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-08-07. Generated as a general project overview; not a coding-style guide._
|
_Last updated: 2026-08-11. Generated as a general project overview; not a coding-style guide._
|
||||||
|
|||||||
Reference in New Issue
Block a user