29 Commits
Author SHA1 Message Date
ivanch b9159cf61f improving general usability and stability
Build and Release to Staging / Build Vision Start (push) Successful in 1m1s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m33s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 14s
2026-10-09 19:30:19 -03:00
ivanch 643e7acf30 fixing pipeline for PR and dependencies 2026-10-09 19:29:21 -03:00
ivanch c42d256454 fixing issue with pings 2026-10-09 19:28:49 -03:00
ivanch 41eb568610 fixing wallpapers again and improving pipeline for release
Build and Release to Staging / Build Vision Start (push) Successful in 1m22s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m40s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 14s
Build and Release / build (push) Successful in 1m21s
Build and Release / virus-total-check (push) Successful in 1m29s
Build and Release / Build Vision Start Image (push) Successful in 2m42s
Build and Release / Deploy Vision Start (production) (push) Successful in 9s
Build and Release / Capture Vision Start Screenshots (push) Successful in 47s
Build and Release / release (push) Successful in 1m0s
2026-09-09 22:30:25 -03:00
ivanch 551bff1e5b adjusting icon cache
Build and Release to Staging / Build Vision Start (push) Successful in 12s
Build and Release / build (push) Successful in 10s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m9s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 8s
Build and Release / virus-total-check (push) Successful in 2m44s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 7s
Build and Release / Capture Vision Start Screenshots (push) Successful in 51s
Build and Release / release (push) Successful in 7s
2026-08-11 22:10:36 -03:00
ivanch d391dc7135 Merge pull request 'Feat/general enhancements' (#4) from feat/general-enhancements into main
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m13s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / build (push) Successful in 11m13s
Build and Release / virus-total-check (push) Successful in 4m37s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
Build and Release / Capture Vision Start Screenshots (push) Successful in 54s
Build and Release / release (push) Successful in 10s
Reviewed-on: #4
2026-08-12 00:25:13 +00:00
ivanch 4885fd68c1 fixing pipeline
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 14s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Successful in 1m3s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Successful in 1s
2026-08-11 19:55:29 -03:00
ivanch 31de8265d9 general fixes & enhancements 2026-08-11 19:08:16 -03:00
ivanch e08853fe54 making wallpapers random
Build and Release to Staging / Build Vision Start (push) Successful in 1m16s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m36s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 8s
2026-08-10 10:05:04 -03:00
ivanch acd8369285 Merge branch 'feat/icon-cache'
Build and Release to Staging / Build Vision Start (push) Successful in 1m14s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m40s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 12s
2026-08-07 12:53:51 -03:00
ivanch 0ecf0eed62 fixing wallpaper issue 2026-08-07 12:53:39 -03:00
ivanch 7635471ed6 adding icon cache 2026-08-07 12:45:12 -03:00
ivanch 8ef643645a Merge pull request 'feat/pipeline-improvements' (#3) from feat/pipeline-improvements into main
Build and Release to Staging / Build Vision Start (push) Successful in 1m12s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m27s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 5s
Reviewed-on: #3
2026-07-11 21:07:16 +00:00
ivanch 552379b2a6 stabilizing pipelines and setting screenshots retention
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 8s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Successful in 56s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Successful in 2s
2026-07-10 23:02:51 -03:00
ivanch f9864072cf fixing version tagging 2026-07-10 22:58:55 -03:00
ivanch 51341c33ca changing screenshots 2026-07-10 22:55:57 -03:00
ivanch 8b5c52dd1e fixing pipeline
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 9s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Successful in 55s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Successful in 1s
2026-07-10 22:53:05 -03:00
ivanch c3addb6d02 adding overwrite flag
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 9s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Failing after 47s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Has been skipped
2026-07-10 22:46:53 -03:00
ivanch 5597afc572 fixing job
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 8s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Failing after 2s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Has been skipped
2026-07-10 22:44:57 -03:00
ivanch 30372f800c adding pr support
Build Pull Request / Build Pull Request Archive (pull_request) Successful in 42s
Build Pull Request / Capture Pull Request Screenshots (pull_request) Failing after 6s
Build Pull Request / Publish Pull Request Screenshot Preview (pull_request) Has been skipped
2026-07-10 22:41:00 -03:00
ivanch 9e738cc0d5 Setting version from tag 2026-07-10 22:08:23 -03:00
ivanch b60c88e9b1 adding license and privacy policy
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m2s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 1s
2026-07-10 22:07:44 -03:00
ivanch fee538f044 fixing issue with icon picker
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 7s
Build and Release / build (push) Successful in 15s
Build and Release / virus-total-check (push) Successful in 38s
Build and Release / release (push) Successful in 4s
Build and Release / Build Vision Start Image (push) Successful in 1m3s
Build and Release / Deploy Vision Start (production) (push) Successful in 2s
2026-07-10 21:18:26 -03:00
ivanch 48ec764880 improving icons and fixing pipeline
Build and Release to Staging / Build Vision Start (push) Successful in 9s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m4s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 2s
Build and Release / build (push) Successful in 51s
Build and Release / virus-total-check (push) Successful in 54s
Build and Release / release (push) Successful in 7s
Build and Release / Build Vision Start Image (push) Successful in 1m3s
Build and Release / Deploy Vision Start (production) (push) Successful in 2s
2026-07-10 20:58:06 -03:00
ivanch babd31548c fixing some things
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 2s
2026-07-10 20:34:43 -03:00
ivanch 95ae04ecd2 moving icons
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 7s
2026-07-10 20:26:04 -03:00
ivanch 7a137abb66 updating manifest and adding icons
Build and Release to Staging / Build Vision Start (push) Failing after 7s
Build and Release to Staging / Build Vision Start Image (push) Has been skipped
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-07-10 20:23:56 -03:00
ivanch 023b2ffdc5 updating pipelines 2026-07-10 20:23:43 -03:00
ivanch c9dafd76d1 adding extra theme options
Build and Release to Staging / Build Vision Start (push) Successful in 13s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m25s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 7s
2026-07-10 20:11:40 -03:00
49 changed files with 2685 additions and 1083 deletions

No files matched your search

+38 -11
View File
@@ -15,27 +15,54 @@ env:
jobs:
build:
name: Build Vision Start
if: gitea.event_name == 'push'
runs-on: ubuntu-amd64
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Setup required tools
run: sudo apt-get install zip jq curl -y
- name: Install JS dependencies
run: npm install
run: npm ci
- name: Run build
run: npm run build
- name: Package dist as zip
run: |
cd dist
zip -r ../vision-start-build.zip .
bash scripts/prepare_release.sh
npm run build
- name: Upload build artifact
- name: Prepare release
run: |
mkdir -p vision-start
mv dist vision-start/
mv extension 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
run: |
cd vision-start
zip -r "../${ARCHIVE_NAME}" *
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: vision-start-build
path: vision-start-build.zip
retention-days: 30
name: release-zip
path: ${{ env.ARCHIVE_NAME }}
build_vision_start:
name: Build Vision Start Image
+180
View File
@@ -0,0 +1,180 @@
name: Build Pull Request
on:
pull_request:
types:
- opened
- reopened
- synchronize
env:
ARCHIVE_NAME: vision-start-pr-${{ gitea.event.pull_request.number }}.zip
jobs:
build:
name: Build Pull Request Archive
runs-on: ubuntu-amd64
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Set up required tools
run: sudo apt-get install zip wget -y
- name: Install JS dependencies
run: npm ci
- name: Run build
run: |
bash scripts/prepare_release.sh
npm run build
- name: Prepare archive
run: |
mkdir -p vision-start
cp -a dist/. vision-start/
cp -a extension vision-start/
cp manifest.json vision-start/
test -f vision-start/index.html
- name: Create zip archive
run: |
cd vision-start
zip -r ../${{ env.ARCHIVE_NAME }} *
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: pr-release-zip
path: ${{ env.ARCHIVE_NAME }}
capture_screenshots:
name: Capture Pull Request Screenshots
runs-on: ubuntu-amd64
needs: build
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Set up required tools
run: sudo apt-get install unzip -y
- name: Install JS dependencies
run: npm ci
- name: Download build artifact
uses: actions/download-artifact@v3
with:
name: pr-release-zip
- name: Unpack build artifact
run: |
mkdir -p dist
unzip -o -q ${{ env.ARCHIVE_NAME }} -d dist
if [ ! -f dist/index.html ]; then
echo "The build artifact does not contain index.html at its root."
unzip -l ${{ env.ARCHIVE_NAME }}
exit 1
fi
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Capture screenshots
run: npm run capture:screenshots
- name: Upload screenshot artifact
uses: actions/upload-artifact@v3
with:
name: pr-screenshots
retention-days: 30
path: |
screenshots/home.png
screenshots/editing.png
screenshots/configuration.png
publish_screenshot_preview:
name: Publish Pull Request Screenshot Preview
runs-on: ubuntu-amd64
needs: capture_screenshots
if: ${{ gitea.event.pull_request.head.repo.full_name == gitea.repository }}
permissions:
actions: read
issues: write
steps:
- name: Set up required tools
run: sudo apt-get install curl jq -y
- name: Download screenshot artifact
uses: actions/download-artifact@v3
with:
name: pr-screenshots
path: screenshots
- name: Update pull request screenshot preview
continue-on-error: true
env:
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
GITEA_TOKEN: ${{ gitea.token }}
REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }}
run: |
set -euo pipefail
OWNER="${REPOSITORY%%/*}"
REPO="${REPOSITORY#*/}"
MARKER='<!-- vision-start-pr-screenshots -->'
COMMENTS_URL="$GITEA_API_URL/repos/$OWNER/$REPO/issues/$PR_NUMBER/comments"
COMMENTS=$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$COMMENTS_URL")
COMMENT_ID=$(printf '%s' "$COMMENTS" | jq -r --arg marker "$MARKER" '.[] | select(.body | contains($marker)) | .id' | tail -n 1)
if [ -z "$COMMENT_ID" ] || [ "$COMMENT_ID" = "null" ]; then
PAYLOAD=$(jq -n --arg body "$MARKER" '{body: $body}')
COMMENT_ID=$(curl -fsS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' \
--data "$PAYLOAD" \
"$COMMENTS_URL" | jq -r '.id')
fi
ASSETS_URL="$GITEA_API_URL/repos/$OWNER/$REPO/issues/comments/$COMMENT_ID/assets"
for ASSET_ID in $(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$ASSETS_URL" | jq -r '.[].id'); do
curl -fsS -X DELETE -H "Authorization: token $GITEA_TOKEN" "$ASSETS_URL/$ASSET_ID"
done
upload_asset() {
curl -fsS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$1;type=image/png" \
"$ASSETS_URL" | jq -r '.browser_download_url'
}
HOME_URL=$(upload_asset screenshots/home.png)
EDITING_URL=$(upload_asset screenshots/editing.png)
CONFIGURATION_URL=$(upload_asset screenshots/configuration.png)
BODY=$(printf '%s\n\n%s\n\n%s\n%s\n\n%s\n%s\n\n%s\n%s' \
"$MARKER" \
'## Visual preview' \
'### Home' \
"![Home]($HOME_URL)" \
'### Editing' \
"![Editing]($EDITING_URL)" \
'### Configuration' \
"![Configuration]($CONFIGURATION_URL)")
PAYLOAD=$(jq -n --arg body "$BODY" '{body: $body}')
curl -fsS -X PATCH \
-H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' \
--data "$PAYLOAD" \
"$GITEA_API_URL/repos/$OWNER/$REPO/issues/comments/$COMMENT_ID"
+138 -12
View File
@@ -15,28 +15,99 @@ jobs:
build:
runs-on: ubuntu-latest
outputs:
zip-file: vision-start-${{ gitea.ref_name }}.zip
zip-file: ${{ steps.set-archive.outputs.archive-name }}
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set version from tag
env:
RELEASE_TAG: ${{ gitea.ref_name }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Expected a vX.Y.Z tag, got: $RELEASE_TAG"
exit 1
fi
VERSION="${RELEASE_TAG#v}"
sed -i -e "s/\"version\": \"0\.0\.0\"/\"version\": \"$VERSION\"/" manifest.json
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- 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
run: npm install
run: npm ci
- name: Run build
run: npm run build
- name: Prepare release
run: |
bash scripts/prepare_release.sh
mv dist vision-start/
mv manifest.json vision-start/
npm run build
- name: Prepare Chrome Web Store package
run: |
mkdir vision-start
cp -a dist/. 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
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
run: |
cd vision-start
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
uses: actions/upload-artifact@v3
with:
name: release-zip
path: vision-start-${{ gitea.ref_name }}.zip
path: ${{ env.ARCHIVE_NAME }}
virus-total-check:
runs-on: ubuntu-latest
@@ -57,7 +128,7 @@ jobs:
id: vt-check
env:
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
VIRUS_TOTAL_FILE: ${{ needs.build.outputs.zip-file }}
run: |
# Run the VirusTotal check script and capture output in real-time
set -o pipefail
@@ -73,7 +144,7 @@ jobs:
release:
runs-on: ubuntu-latest
needs: [build, virus-total-check]
needs: [build, virus-total-check, capture_screenshots]
steps:
- name: Check out repository code
uses: actions/checkout@v4
@@ -81,6 +152,11 @@ jobs:
uses: actions/download-artifact@v3
with:
name: release-zip
- name: Download screenshot artifacts
uses: actions/download-artifact@v3
with:
name: release-screenshots
path: release-screenshots
- name: Release zip
uses: akkuman/gitea-release-action@v1
with:
@@ -91,7 +167,11 @@ jobs:
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
name: ${{ gitea.ref_name }}
tag_name: ${{ gitea.ref_name }}
files: vision-start-${{ gitea.ref_name }}.zip
files: |
${{ needs.build.outputs.zip-file }}
release-screenshots/home.png
release-screenshots/editing.png
release-screenshots/configuration.png
build_vision_start:
name: Build Vision Start Image
@@ -101,6 +181,17 @@ jobs:
- name: Check out repository
uses: actions/checkout@v2
- name: Set version from tag
env:
RELEASE_TAG: ${{ gitea.ref_name }}
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Expected a vX.Y.Z tag, got: $RELEASE_TAG"
exit 1
fi
VERSION="${RELEASE_TAG#v}"
sed -i -e "s/\"version\": \"0\.0\.0\"/\"version\": \"$VERSION\"/" manifest.json
- name: Log in to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
@@ -136,3 +227,38 @@ jobs:
cd ${{ secrets.PROD_DIR }}
docker compose pull
docker compose up -d --force-recreate
capture_screenshots:
name: Capture Vision Start Screenshots
runs-on: ubuntu-amd64
needs: deploy_vision_start
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install JS dependencies
run: npm ci
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Capture release screenshots
env:
SCREENSHOT_BASE_URL: http://vision-start.ivanch.me
run: npm run capture:screenshots
- name: Upload release screenshots
uses: actions/upload-artifact@v3
with:
name: release-screenshots
retention-days: 30
path: |
screenshots/home.png
screenshots/editing.png
screenshots/configuration.png
+40 -88
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import ServerWidget from './components/ServerWidget';
import { DEFAULT_CATEGORIES } from './constants';
import { Category, Website, Config } from './types';
import Header from './components/layout/Header';
import EditButton from './components/layout/EditButton';
@@ -8,49 +7,17 @@ import ConfigurationButton from './components/layout/ConfigurationButton';
import CategoryGroup from './components/layout/CategoryGroup';
import Wallpaper from './components/Wallpaper';
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';
import { createId } from './components/utils/idUtils';
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
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 App: React.FC = () => {
const [categories, setCategories] = useState<Category[]>(() => {
try {
const storedCategories = localStorage.getItem('categories');
if (storedCategories) {
return JSON.parse(storedCategories);
}
} catch (error) {
console.error('Error parsing categories from localStorage', error);
}
return DEFAULT_CATEGORIES;
});
const [categories, setCategories] = useState<Category[]>(() => ConfigurationService.loadCategories());
const [isEditing, setIsEditing] = useState(false);
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [editingWebsite, setEditingWebsite] = useState<Website | null>(null);
@@ -81,23 +48,15 @@ const App: React.FC = () => {
setConfig(prev => ({ ...prev, ...newConfig }));
}, []);
const handleNextWallpaper = useCallback(() => {
const handleRandomWallpaper = useCallback(() => {
const names = config.currentWallpapers;
if (names.length === 0) return;
try {
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
const nextIndex = (safeCurrent + 1) % names.length;
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: nextIndex,
}),
);
const { currentIndex } = loadWallpaperState(names);
const randomIndex = getRandomWallpaperIndex(names.length, currentIndex);
saveWallpaperState(names, randomIndex, Date.now());
} catch (error) {
console.error('Error advancing wallpaper state', error);
console.error('Error randomizing wallpaper state', error);
}
setWallpaperVersion(v => v + 1);
}, [config.currentWallpapers]);
@@ -105,90 +64,87 @@ const App: React.FC = () => {
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
if (editingWebsite) {
const idToUpdate = website.id ?? editingWebsite.id;
const newCategories = categories.map(category => ({
setCategories(previous => previous.map(category => category.id === editingWebsite.categoryId ? {
...category,
websites: category.websites.map(w =>
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate } : w
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate, categoryId: category.id } : w
),
}));
setCategories(newCategories);
} : category));
setEditingWebsite(null);
} else if (addingWebsite) {
const newWebsite: Website = {
id: Date.now().toString(),
id: createId(),
name: website.name || '',
url: website.url || '',
icon: website.icon || '',
categoryId: addingWebsite.id,
};
const newCategories = categories.map(category =>
setCategories(previous => previous.map(category =>
category.id === addingWebsite.id
? { ...category, websites: [...category.websites, newWebsite] }
: category
);
setCategories(newCategories);
));
setAddingWebsite(null);
}
}, [editingWebsite, addingWebsite, categories]);
}, [editingWebsite, addingWebsite]);
const handleSaveCategory = useCallback((name: string) => {
if (editingCategory) {
const newCategories = categories.map(category =>
setCategories(previous => previous.map(category =>
category.id === editingCategory.id ? { ...category, name } : category
);
setCategories(newCategories);
));
} else {
const newCategory: Category = {
id: Date.now().toString(),
id: createId(),
name,
websites: [],
};
setCategories([...categories, newCategory]);
setCategories(previous => [...previous, newCategory]);
}
setEditingCategory(null);
setIsCategoryModalOpen(false);
}, [editingCategory, categories]);
}, [editingCategory]);
const handleDeleteWebsite = useCallback(() => {
if (!editingWebsite) return;
const newCategories = categories.map(category => ({
setCategories(previous => previous.map(category => category.id === editingWebsite.categoryId ? {
...category,
websites: category.websites.filter(w => w.id !== editingWebsite.id),
}));
setCategories(newCategories);
} : category));
setEditingWebsite(null);
}, [editingWebsite, categories]);
}, [editingWebsite]);
const handleDeleteCategory = useCallback(() => {
if (!editingCategory) return;
const newCategories = categories.filter(c => c.id !== editingCategory.id);
setCategories(newCategories);
setCategories(previous => previous.filter(c => c.id !== editingCategory.id));
setEditingCategory(null);
setIsCategoryModalOpen(false);
}, [editingCategory, categories]);
}, [editingCategory]);
const handleMoveWebsite = useCallback((website: Website, direction: 'left' | 'right') => {
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
if (categoryIndex === -1) return;
setCategories(previous => {
const categoryIndex = previous.findIndex(cat => cat.id === website.categoryId);
if (categoryIndex === -1) return previous;
const category = categories[categoryIndex];
const category = previous[categoryIndex];
const websiteIndex = category.websites.findIndex(w => w.id === website.id);
if (websiteIndex === -1) return;
if (websiteIndex === -1) return previous;
const targetIndex = direction === 'left' ? websiteIndex - 1 : websiteIndex + 1;
if (targetIndex < 0 || targetIndex >= category.websites.length) return;
if (targetIndex < 0 || targetIndex >= category.websites.length) return previous;
const newWebsites = [...category.websites];
const [movedWebsite] = newWebsites.splice(websiteIndex, 1);
newWebsites.splice(targetIndex, 0, movedWebsite);
const newCategories = [...categories];
const newCategories = [...previous];
newCategories[categoryIndex] = { ...category, websites: newWebsites };
setCategories(newCategories);
}, [categories]);
return newCategories;
});
}, []);
return (
<main
@@ -218,13 +174,12 @@ const App: React.FC = () => {
setAddingWebsite={setAddingWebsite}
setEditingWebsite={setEditingWebsite}
handleMoveWebsite={handleMoveWebsite}
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
horizontalAlignment={config.horizontalAlignment}
tileSize={config.tileSize}
/>
))}
{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
onClick={() => {
setEditingCategory(null);
@@ -233,10 +188,7 @@ const App: React.FC = () => {
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
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">
<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>
<PlusIcon size={22} />
Add category
</button>
</div>
@@ -282,7 +234,7 @@ const App: React.FC = () => {
onClose={() => setIsConfigModalOpen(false)}
onSave={handleSaveConfig}
onWallpaperChange={handleWallpaperChange}
onNextWallpaper={handleNextWallpaper}
onRandomWallpaper={handleRandomWallpaper}
/>
</Suspense>
)}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Vision Startpage Project
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
# Privacy Policy for Vision Start
This Privacy Policy describes how the Vision Start Chrome extension handles user information.
## Information Stored by the Extension
Vision Start stores extension settings and user-provided configuration locally in the browser using Chrome's local extension storage.
This information may include:
* Start-page preferences and appearance settings;
* Bookmarks, shortcuts, or server and services addresses added by the user;
* Configuration for features enabled by the user.
This information is used only to provide the extension's functionality. It is not transmitted to, collected by, or stored on servers operated by the developer.
The developer does not have access to information stored locally by the extension.
## Server Status Requests
Vision Start can send network requests to server addresses configured by the user in order to check whether those servers are available.
These requests are sent directly from the user's browser to the configured server. The developer does not receive, proxy, log, or store these requests.
The destination server may receive standard network information associated with the request, such as the user's IP address, browser request headers, and the requested server address. The handling of that information is controlled by the operator of the destination server.
## External Favicon Service
Vision Start uses the Google Favicon Service (https://www.google.com/s2/favicons) to retrieve icons for websites configured by the user.
When an icon is requested, the relevant website domain is sent directly from the user's browser to Google. Google may also receive standard information associated with the network request, such as the user's IP address, browser request headers, and the requested domain.
The developer does not receive, proxy, log, or store these requests. Google's handling of information is governed by the Google Privacy Policy.
## Data Sharing and Sale
The developer does not sell, rent, trade, or use user information for advertising, analytics, profiling, or marketing.
No locally stored extension data is shared with the developer or with third parties, except for the direct network requests described above that are necessary to provide user-requested features.
## Data Retention and Deletion
Information stored by Vision Start remains in the browser until the user:
* Deletes the relevant information through the extension;
* Clears the extension's browser storage; or
* Uninstalls the extension.
## Security
Vision Start is designed to keep user configuration on the user's device. Users should use HTTPS addresses whenever possible when configuring remote servers or external resources.
## Changes to This Policy
This Privacy Policy may be updated if the extension's functionality or data-handling practices change.
Any updated version will be published at this location with a revised effective date. Material changes to how user information is handled will also be disclosed through the extension or its Chrome Web Store listing where appropriate.
## Contact
Questions about this Privacy Policy may be submitted through the developer's GitHub profile: https://github.com/ivanch
+4 -4
View File
@@ -1,9 +1,9 @@
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
Vision Start
<div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
<img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
</div>
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
A light liquid-glass, modern and customizable startpage built with React.
A light, modern and customizable startpage built with React.
</div>
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
@@ -63,7 +63,7 @@ npm run dev
* [x] Multiple Wallpapers
* [x] Remake icons
* [/] Increase offline compatibility (might not be possible)
- [x] Use chrome.storage.local for user wallpapers -- this one is
- [x] Use chrome.storage.local for uploaded wallpaper files; remote wallpapers stay as URLs
- [ ] Use chrome.storage.local for some logos -- a bit hard
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
* Dynamic Weather Widget
+9 -30
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Category } from '../types';
import ModalShell from './ModalShell';
interface CategoryEditModalProps {
category?: Category;
@@ -12,17 +13,14 @@ interface CategoryEditModalProps {
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(category ? category.name : '');
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">{edit ? 'Edit Category' : 'Add Category'}</h2>
<div className="flex flex-col gap-4">
<ModalShell
title={edit ? 'Edit Category' : 'Add Category'}
edit={edit}
onClose={onClose}
onSave={() => onSave(name)}
onDelete={edit ? onDelete : undefined}
>
<input
type="text"
placeholder="Category Name"
@@ -30,26 +28,7 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
onChange={(e) => setName(e.target.value)}
className="liquid-input p-3"
/>
</div>
<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>
</ModalShell>
);
};
+16 -15
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { getClockSizeClass } from './utils/styleUtils';
interface ClockProps {
config: {
@@ -9,31 +10,31 @@ interface ClockProps {
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());
useEffect(() => {
if (!config.clock.enabled) return;
const scheduleNext = () => {
const now = new Date();
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
timeoutId = window.setTimeout(() => {
setTime(new Date());
intervalId = window.setInterval(() => setTime(new Date()), 60_000);
}, msToNextMinute);
};
let timeoutId = 0;
let intervalId = 0;
scheduleNext();
const refresh = () => {
window.clearTimeout(timeoutId);
if (document.visibilityState !== 'visible') return;
const now = new Date();
setTime(now);
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
timeoutId = window.setTimeout(refresh, msToNextMinute);
};
refresh();
document.addEventListener('visibilitychange', refresh);
window.addEventListener('focus', refresh);
return () => {
if (timeoutId) window.clearTimeout(timeoutId);
if (intervalId) window.clearInterval(intervalId);
window.clearTimeout(timeoutId);
document.removeEventListener('visibilitychange', refresh);
window.removeEventListener('focus', refresh);
};
}, [config.clock.enabled]);
+38 -29
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, lazy, Suspense } from 'react';
import { Config, Wallpaper } from '../types';
import { baseWallpapers } from './utils/baseWallpapers';
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
@@ -6,14 +6,14 @@ import { ConfigurationService } from './services/ConfigurationService';
import GeneralTab from './configuration/GeneralTab';
import ThemeTab from './configuration/ThemeTab';
import ClockTab from './configuration/ClockTab';
import ServerWidgetTab from './configuration/ServerWidgetTab';
const ServerWidgetTab = lazy(() => import('./configuration/ServerWidgetTab'));
interface ConfigurationModalProps {
onClose: () => void;
onSave: (config: Config) => void;
currentConfig: Config;
onWallpaperChange: (newConfig: Partial<Config>) => void;
onNextWallpaper: () => void;
onRandomWallpaper: () => void;
}
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
@@ -21,16 +21,17 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
onSave,
currentConfig,
onWallpaperChange,
onNextWallpaper,
onRandomWallpaper,
}) => {
const [config, setConfig] = useState<Config>(currentConfig);
const [activeTab, setActiveTab] = useState('general');
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const wallpaperMutation = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
@@ -44,57 +45,63 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
useEffect(() => {
return () => {
clearTimeout(closeTimer.current);
if (!isSaving.current) {
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
const availableNames = new Set([...baseWallpapers, ...ConfigurationService.loadUserWallpapers()].map(wallpaper => wallpaper.name));
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers.filter(name => availableNames.has(name)) });
}
};
}, []);
useEffect(() => {
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
ConfigurationService.resetWallpaperState();
}, [config.currentWallpapers]);
const handleClose = () => {
setIsVisible(false);
setTimeout(onClose, 250);
clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(onClose, 250);
};
const handleConfigChange = (updates: Partial<Config>) => {
setConfig((prev) => ({ ...prev, ...updates }));
};
const handleAddWallpaper = async (name: string, url: string) => {
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
const mutateWallpapers = (mutate: () => Promise<void>): Promise<void> => {
const operation = wallpaperMutation.current.then(mutate);
wallpaperMutation.current = operation.catch(() => {});
return operation;
};
const handleAddWallpaperFile = async (file: File) => {
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
const handleAddWallpaperEntry = (createWallpaper: () => Promise<Wallpaper>) => mutateWallpapers(async () => {
const newWallpaper = await createWallpaper();
const updated = [...ConfigurationService.loadUserWallpapers(), newWallpaper];
if (updated.slice(0, -1).some(wallpaper => wallpaper.name === newWallpaper.name)) {
throw new Error('A wallpaper with this name already exists. Please choose a different name.');
}
ConfigurationService.saveUserWallpapers(updated);
setUserWallpapers(updated);
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
};
});
const handleAddWallpaper = (name: string, url: string) =>
handleAddWallpaperEntry(() => ConfigurationService.addWallpaper(name, url));
const handleAddWallpaperFile = (file: File) =>
handleAddWallpaperEntry(() => ConfigurationService.addWallpaperFile(file));
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
try {
await mutateWallpapers(async () => {
await ConfigurationService.deleteWallpaper(wallpaper);
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
setUserWallpapers(updated);
const updated = ConfigurationService.loadUserWallpapers().filter((w) => w.name !== wallpaper.name);
ConfigurationService.saveUserWallpapers(updated);
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
setUserWallpapers(updated);
setConfig((prev) => ({ ...prev, currentWallpapers: prev.currentWallpapers.filter(name => name !== wallpaper.name) }));
});
} catch (error) {
alert('Error deleting wallpaper. Please try again.');
console.error(error);
@@ -110,6 +117,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
setConfig(importedConfig);
setUserWallpapers(importedWallpapers);
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
isSaving.current = true;
onSave(importedConfig);
alert('Configuration imported successfully. The page will reload to apply all data.');
window.location.reload();
@@ -140,7 +148,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
/>
<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 ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
@@ -177,14 +184,16 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
onAddWallpaper={handleAddWallpaper}
onAddWallpaperFile={handleAddWallpaperFile}
onDeleteWallpaper={handleDeleteWallpaper}
onNextWallpaper={onNextWallpaper}
onRandomWallpaper={onRandomWallpaper}
/>
)}
{activeTab === 'clock' && (
<ClockTab config={config} onChange={handleConfigChange} />
)}
{activeTab === 'serverWidget' && (
<Suspense fallback={null}>
<ServerWidgetTab config={config} onChange={handleConfigChange} />
</Suspense>
)}
</div>
+46
View File
@@ -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;
+49 -36
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Server } from '../types';
import ping from './utils/jsping.js';
import ping from './utils/jsping';
interface ServerWidgetProps {
config: {
@@ -12,58 +12,71 @@ interface ServerWidgetProps {
};
}
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
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 STATUS_COLORS: Record<string, string> = {
online: 'bg-green-400 text-green-400',
offline: 'bg-red-400 text-red-400',
};
const getStatusColor = (status: string): string =>
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
const serversRef = useRef(config.serverWidget.servers);
serversRef.current = config.serverWidget.servers;
const serversSignature = config.serverWidget.servers
.map(s => `${s.id}:${s.address}`)
.join('|');
const serverCount = config.serverWidget.servers.length;
const serversSignature = JSON.stringify(config.serverWidget.servers.map(s => [s.id, s.address]));
useEffect(() => {
if (!config.serverWidget.enabled) return;
if (!config.serverWidget.enabled || serversRef.current.length === 0) return;
const pingServers = () => {
const pending: Record<string, string> = {};
for (const s of serversRef.current) pending[s.id] = 'pending';
setServerStatus(prev => ({ ...prev, ...pending }));
let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let cancelled = false;
const frequency = Math.min(60, Math.max(5, config.serverWidget.pingFrequency || 15)) * 1000;
setServerStatus(Object.fromEntries(serversRef.current.map(server => [server.id, 'pending'])));
serversRef.current.forEach((server) => {
ping(server.address)
.then(() => {
setServerStatus(prev =>
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
);
})
.catch(() => {
setServerStatus(prev =>
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
);
});
});
const pingServers = async () => {
if (cancelled || document.visibilityState !== 'visible') return;
const cycle = new AbortController();
controller = cycle;
const servers = serversRef.current;
const statuses: Record<string, string> = {};
let next = 0;
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, async () => {
while (next < servers.length && !cycle.signal.aborted) {
const server = servers[next++];
try {
await ping(server.address, 1, cycle.signal);
statuses[server.id] = 'online';
} catch {
statuses[server.id] = 'offline';
}
}
}));
if (cancelled || cycle.signal.aborted) return;
setServerStatus(previous => servers.every(server => previous[server.id] === statuses[server.id])
? previous : statuses);
timer = setTimeout(() => void pingServers(), frequency);
};
const onVisibilityChange = () => {
clearTimeout(timer);
controller?.abort();
if (document.visibilityState === 'visible') void pingServers();
};
pingServers();
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
return () => clearInterval(interval);
void pingServers();
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
cancelled = true;
clearTimeout(timer);
controller?.abort();
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [
config.serverWidget.enabled,
config.serverWidget.pingFrequency,
serversSignature,
serverCount,
]);
if (!config.serverWidget.enabled) {
+70 -58
View File
@@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
import { getRandomWallpaperIndex, getWallpaperFrequencyMs, loadWallpaperState, saveWallpaperState } from './utils/wallpaperUtils';
import { ConfigurationService } from './services/ConfigurationService';
interface WallpaperProps {
wallpaperNames: string[];
@@ -13,19 +14,19 @@ interface WallpaperProps {
wallpaperVersion: number;
}
const parseFrequencyToMs = (freq: string): number => {
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
const match = freq.match(/(\d+)(h|d)/);
if (!match) return 24 * 60 * 60 * 1000;
const value = parseInt(match[1], 10);
const unit = match[2];
if (unit === 'h') return value * 60 * 60 * 1000;
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
return 24 * 60 * 60 * 1000;
};
const MAX_WALLPAPER_URL_CACHE = 3;
const wallpaperUrlCache = new Map<string, string | undefined>();
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
if (resolved?.startsWith('data:')) return;
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> => {
if (!name) return undefined;
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
@@ -36,70 +37,60 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
resolved = foundInBase.url || foundInBase.base64;
} else {
try {
const storedUserWallpapers: WallpaperType[] =
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
const storedUserWallpapers = ConfigurationService.loadUserWallpapers();
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
if (foundInUser) {
resolved = foundInUser.url || foundInUser.base64;
if (!resolved) {
try {
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
if (wallpaperData && wallpaperData.startsWith('http')) {
resolved = wallpaperData;
} else {
resolved = wallpaperData || undefined;
}
resolved = (await getWallpaperFromChromeStorageLocal(name)) || undefined;
} catch (error) {
console.error('Error getting wallpaper from chrome storage', error);
resolved = undefined;
}
}
}
} catch (error) {
console.error('Error reading userWallpapers from localStorage', error);
resolved = undefined;
}
}
wallpaperUrlCache.set(name, resolved);
rememberWallpaperUrl(name, resolved);
return resolved;
};
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const resolvedRef = useRef(false);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let revision = 0;
wallpaperUrlCache.clear();
const updateWallpaper = async () => {
const request = ++revision;
clearTimeout(timer);
if (document.visibilityState !== 'visible') return;
if (wallpaperNames.length === 0) {
setImageUrl(undefined);
localStorage.setItem(
'wallpaperState',
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
);
saveWallpaperState([], 0, loadWallpaperState([]).lastChange);
return;
}
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const lastChange = wallpaperState.lastWallpaperChange
? new Date(wallpaperState.lastWallpaperChange).getTime()
: 0;
const now = Date.now();
const freqMs = parseFrequencyToMs(wallpaperFrequency);
let storedIndex =
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
const shouldRotate = now - lastChange >= freqMs;
const { currentIndex, lastChange } = loadWallpaperState(wallpaperNames, now);
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
const shouldRotate = wallpaperNames.length > 1 && now - lastChange >= freqMs;
let resolvedIndex = shouldRotate
? (storedIndex + 1) % wallpaperNames.length
: storedIndex;
const tried = new Set<number>();
? getRandomWallpaperIndex(wallpaperNames.length, currentIndex)
: currentIndex;
let resolvedUrl: string | undefined;
for (let i = 0; i < wallpaperNames.length; i++) {
if (tried.has(resolvedIndex)) break;
tried.add(resolvedIndex);
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
if (cancelled || request !== revision) return;
if (url) {
resolvedUrl = url;
break;
@@ -107,22 +98,43 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
}
const nextLastChange = shouldRotate
? new Date().toISOString()
: wallpaperState.lastWallpaperChange || new Date().toISOString();
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: nextLastChange,
currentIndex: resolvedIndex,
}),
);
resolvedRef.current = true;
if (cancelled || request !== revision) return;
const nextLastChange = shouldRotate || resolvedIndex !== currentIndex ? Date.now() : lastChange;
saveWallpaperState(wallpaperNames, resolvedIndex, nextLastChange);
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();
else {
++revision;
clearTimeout(timer);
}
};
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);
wallpaperUrlCache.clear();
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('focus', refresh);
window.removeEventListener('storage', onStorage);
};
updateWallpaper();
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
if (!imageUrl) return null;
+52 -65
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService';
import ModalShell from './ModalShell';
interface WebsiteEditModalProps {
website?: Website;
@@ -13,19 +14,11 @@ interface WebsiteEditModalProps {
interface IconMetadata {
name: string;
base: string;
aliases: string[];
categories: string[];
update: {
timestamp: string;
author: {
id: number;
name: string;
};
};
colors: any;
colors: string[];
}
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 [name, setName] = useState(website ? website.name : '');
@@ -34,23 +27,44 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
const [iconQuery, setIconQuery] = useState('');
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [iconsFetched, setIconsFetched] = useState(false);
const metadataRequest = useRef<AbortController | null>(null);
const debounceRef = useRef<number | null>(null);
useEffect(() => {
return () => {
metadataRequest.current?.abort();
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
};
}, []);
const ensureIconMetadata = () => {
if (iconMetadataCache || iconsFetched) return;
setIconsFetched(true);
fetch('/icon-metadata.json', { cache: 'force-cache' })
.then(response => response.json())
if (iconMetadata.length > 0 || metadataRequest.current) return;
const controller = new AbortController();
metadataRequest.current = controller;
const timeout = window.setTimeout(() => controller.abort(), 10_000);
fetch(new URL('icon-metadata.json', document.baseURI).href, { cache: 'force-cache', signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error('Icon metadata is unavailable');
return response.json();
})
.then(data => {
const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({
name,
...(details as object),
})) as IconMetadata[];
iconMetadataCache = iconsArray;
if (controller.signal.aborted) return;
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid icon metadata');
const iconsArray: IconMetadata[] = Object.entries(data).flatMap(([name, details]) => {
if (!details || typeof details !== 'object' || !('base' in details) || typeof details.base !== 'string') return [];
const colors = 'colors' in details && details.colors && typeof details.colors === 'object'
? Object.values(details.colors).filter((color): color is string => typeof color === 'string' && color !== name) : [];
return [{ name, base: details.base, colors }];
});
setIconMetadata(iconsArray);
})
.catch(err => console.error('Failed to load icon metadata', err));
.catch(err => {
if (!controller.signal.aborted) console.error('Failed to load icon metadata', err);
})
.finally(() => {
window.clearTimeout(timeout);
if (metadataRequest.current === controller) metadataRequest.current = null;
});
};
useEffect(() => {
@@ -64,10 +78,9 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
filtered.push(ic);
if (filtered.length >= 50) break;
}
if (ic.colors) {
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
for (const color of colors) {
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
if (ic.colors.length > 0) {
for (const color of ic.colors) {
if (color.toLowerCase().includes(lowerCaseQuery)) {
filtered.push({ ...ic, name: color });
if (filtered.length >= 50) break;
}
@@ -92,21 +105,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 (
<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">{edit ? 'Edit Website' : 'Add Website'}</h2>
<div className="flex flex-col gap-4">
<ModalShell
title={edit ? 'Edit Website' : 'Add Website'}
edit={edit}
onClose={onClose}
onSave={() => onSave({ id: website?.id, name, url, icon })}
onDelete={edit ? onDelete : undefined}
>
<div className="flex justify-center mb-4">
{icon ? (
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
@@ -149,18 +155,18 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
/>
{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">
{filteredIcons.map(iconData => (
{filteredIcons.map((iconData, index) => (
<div
key={iconData.name}
key={`${iconData.name}-${index}`}
onClick={() => {
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
setIcon(iconUrl);
setIcon(getIconPickUrl(iconData));
setIconQuery('');
setFilteredIcons([]);
}}
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
>
<img
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
src={getIconPickUrl(iconData)}
alt={iconData.name}
className="h-6 w-6 mr-2"
/>
@@ -174,26 +180,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
Fetch
</button>
</div>
</div>
<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>
</ModalShell>
);
};
+71 -54
View File
@@ -1,5 +1,8 @@
import React, { memo, useState } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { Website } from '../types';
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
interface WebsiteTileProps {
website: Website;
@@ -9,60 +12,70 @@ interface WebsiteTileProps {
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 [isLoading, setIsLoading] = useState(false);
const [iconSource, setIconSource] = useState<string | null>(null);
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
useEffect(() => {
const resetLoading = () => setIsLoading(false);
window.addEventListener('pageshow', resetLoading);
return () => window.removeEventListener('pageshow', resetLoading);
}, []);
useEffect(() => {
let cancelled = false;
setIconSource(null);
setUsingCachedIcon(false);
const loadIcon = async () => {
const cachedIcon = await getCachedWebsiteIcon(website.icon);
if (cancelled) return;
if (cachedIcon) {
setIconSource(cachedIcon);
setUsingCachedIcon(cachedIcon !== website.icon);
return;
}
setIconSource(website.icon);
const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
if (!cancelled && newlyCachedIcon) {
setIconSource(newlyCachedIcon);
setUsingCachedIcon(newlyCachedIcon !== website.icon);
}
};
void loadIcon();
return () => {
cancelled = true;
};
}, [website.icon]);
const handleIconError = () => {
if (!usingCachedIcon) {
setIconSource(null);
return;
}
setIconSource(website.icon);
setUsingCachedIcon(false);
void removeCachedWebsiteIcon(website.icon);
};
const handleClick = (e: React.MouseEvent) => {
if (isEditing) {
e.preventDefault();
return;
}
if (!e.defaultPrevented && e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) {
setIsLoading(true);
}
};
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
const iconSize = isLoading ? getIconLoadingPixelSize(tileSize) : getIconPixelSize(tileSize);
return (
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios ${isEditing ? 'mb-4' : ''}`}>
@@ -83,8 +96,18 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</div>
)}
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" />
<div className="flex items-center justify-center transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)]" style={{ width: iconSize, height: iconSize }}>
{iconSource ? (
<img
src={iconSource}
alt={`${website.name} icon`}
className="object-contain w-full h-full"
decoding="async"
onError={handleIconError}
/>
) : (
<span className="text-2xl font-semibold text-slate-300" aria-hidden="true">{website.name.charAt(0).toUpperCase()}</span>
)}
</div>
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
{website.name}
@@ -93,15 +116,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
</a>
{isEditing && (
<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">
<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></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>
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></button>
</div>
)}
</div>
+2 -6
View File
@@ -2,6 +2,7 @@ import React from 'react';
import Dropdown from '../Dropdown';
import ToggleSwitch from '../ToggleSwitch';
import { Config } from '../../types';
import { SIZE_OPTIONS } from '../utils/styleUtils';
interface ClockTabProps {
config: Config;
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
name="clock.size"
value={config.clock.size}
onChange={(e) => updateClock({ size: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
options={SIZE_OPTIONS}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+2 -6
View File
@@ -1,6 +1,7 @@
import React from 'react';
import Dropdown from '../Dropdown';
import { Config } from '../../types';
import { SIZE_OPTIONS } from '../utils/styleUtils';
interface GeneralTabProps {
config: Config;
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
name="titleSize"
value={config.titleSize}
onChange={(e) => onChange({ titleSize: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
options={SIZE_OPTIONS}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+53
View File
@@ -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;
+15 -39
View File
@@ -1,20 +1,16 @@
import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import ToggleSwitch from '../ToggleSwitch';
import { Config, Server } from '../../types';
import RangeSlider from './RangeSlider';
import { TrashIcon } from '../icons';
import { createId } from '../utils/idUtils';
interface ServerWidgetTabProps {
config: Config;
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 [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
@@ -26,9 +22,9 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
const newServer: Server = {
id: Date.now().toString(),
name: newServerName,
address: newServerAddress,
id: createId(),
name: newServerName.trim(),
address: newServerAddress.trim(),
};
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
setNewServerName('');
@@ -41,7 +37,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
});
};
const onDragEnd = (result: any) => {
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(config.serverWidget.servers);
const [reorderedItem] = items.splice(result.source.index, 1);
@@ -60,21 +56,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
</div>
{config.serverWidget.enabled && (
<>
<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">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="60"
<RangeSlider
label="Ping Frequency"
value={config.serverWidget.pingFrequency}
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
min={5}
max={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>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
@@ -103,20 +92,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Remove ${server.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
<TrashIcon size={16} />
</button>
</div>
)}
+52 -91
View File
@@ -1,6 +1,14 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import Dropdown from '../Dropdown';
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 {
config: Config;
@@ -11,16 +19,9 @@ interface ThemeTabProps {
onAddWallpaper: (name: string, url: string) => Promise<void>;
onAddWallpaperFile: (file: File) => Promise<void>;
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
onNextWallpaper: () => 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 ThemeTab: React.FC<ThemeTabProps> = ({
config,
onChange,
@@ -30,11 +31,11 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
onAddWallpaper,
onAddWallpaperFile,
onDeleteWallpaper,
onNextWallpaper,
onRandomWallpaper,
}) => {
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
const handleAddWallpaper = async () => {
if (newWallpaperUrl.trim() === '') return;
@@ -43,7 +44,11 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
setNewWallpaperName('');
setNewWallpaperUrl('');
} catch (error) {
alert('Error adding wallpaper. Please check the URL and try again.');
alert(
error instanceof Error
? error.message
: 'Error adding wallpaper. Please check the URL and try again.',
);
console.error(error);
}
};
@@ -53,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
if (!file) return;
try {
await onAddWallpaperFile(file);
} catch (error: any) {
alert(error?.message || 'Error adding wallpaper. Please try again.');
} catch (error) {
alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
console.error(error);
}
e.target.value = '';
@@ -73,70 +78,39 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
/>
</div>
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<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">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
<RangeSlider
label="Change Frequency"
value={wallpaperFrequencyHours}
min={MIN_WALLPAPER_FREQUENCY_HOURS}
max={MAX_WALLPAPER_FREQUENCY_HOURS}
formatValue={formatWallpaperFrequency}
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
/>
</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 Blur</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
<RangeSlider
label="Wallpaper Blur"
value={config.wallpaperBlur}
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
min={0}
max={50}
valueSuffix="px"
onChange={(value) => onChange({ wallpaperBlur: value })}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
</div>
</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"
<RangeSlider
label="Wallpaper Brightness"
value={config.wallpaperBrightness}
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
min={0}
max={200}
valueSuffix="%"
onChange={(value) => onChange({ wallpaperBrightness: value })}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
</div>
</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"
<RangeSlider
label="Wallpaper Opacity"
value={config.wallpaperOpacity}
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
min={1}
max={100}
valueSuffix="%"
onChange={(value) => onChange({ wallpaperOpacity: value })}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
</div>
</div>
{chromeStorageAvailable && (
<>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
@@ -151,20 +125,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
<TrashIcon size={16} />
</button>
</div>
))}
@@ -195,6 +156,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
Add
</button>
</div>
{chromeStorageAvailable && (
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
@@ -217,26 +179,25 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
/>
</svg>
<p className="mb-2 text-sm text-gray-400">
<span className="font-semibold">Click to upload</span> or drag and drop
<span className="font-semibold">Click to upload</span>
</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input
id="file-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleFileUpload}
ref={fileInputRef}
/>
</label>
</div>
</div>
</div>
</>
)}
</div>
</div>
<div className="flex justify-center pt-2">
<button
onClick={onNextWallpaper}
onClick={onRandomWallpaper}
disabled={config.currentWallpapers.length === 0}
className="liquid-surface liquid-control liquid-focus disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-2xl"
>
@@ -249,7 +210,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
>
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 1 .5-.5h5.379L8.646 5.354a.5.5 0 1 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708L10.379 8H5a.5.5 0 0 1-.5-.5z" />
</svg>
Next Wallpaper
Random Wallpaper
</button>
</div>
</div>
+38
View File
@@ -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>
);
+7 -25
View File
@@ -1,6 +1,8 @@
import React, { memo } from 'react';
import WebsiteTile from '../WebsiteTile';
import { Category, Website } from '../../types';
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
import { PencilIcon, PlusIcon } from '../icons';
interface CategoryGroupProps {
category: Category;
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
setAddingWebsite: (category: Category) => void;
setEditingWebsite: (website: Website) => void;
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
getHorizontalAlignmentClass: (alignment: string) => string;
horizontalAlignment: 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> = ({
category,
isEditing,
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
setAddingWebsite,
setEditingWebsite,
handleMoveWebsite,
getHorizontalAlignmentClass,
horizontalAlignment,
tileSize,
}) => {
return (
<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>
{isEditing && (
<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'}`}
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">
<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>
<PencilIcon size={14} />
</button>
)}
</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) => (
<WebsiteTile
key={website.id}
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
{isEditing && (
<button
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}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<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>
<PlusIcon size={28} />
<span className="text-sm font-bold">Add</span>
</button>
)}
+2 -4
View File
@@ -1,4 +1,4 @@
import { PencilIcon } from '../icons';
interface EditButtonProps {
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' : ''}`}
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">
<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>
<PencilIcon size={16} />
{isEditing ? 'Done' : ''}
</button>
</div>
+2 -48
View File
@@ -1,63 +1,17 @@
import Clock from '../Clock';
import { Config } from '../../types';
import { getTitleSizeClass } from '../utils/styleUtils';
interface HeaderProps {
config: Config;
}
const getClockSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-3xl';
case 'small':
return 'text-4xl';
case 'medium':
return 'text-5xl';
case 'large':
return 'text-6xl';
default:
return 'text-5xl';
}
};
const getTitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-4xl';
case 'small':
return 'text-5xl';
case 'medium':
return 'text-6xl';
case 'large':
return 'text-7xl';
default:
return 'text-6xl';
}
};
const getSubtitleSizeClass = (size: string) => {
switch (size) {
case 'tiny':
return 'text-lg';
case 'small':
return 'text-xl';
case 'medium':
return 'text-2xl';
case 'large':
return 'text-3xl';
default:
return 'text-2xl';
}
};
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
const Header: React.FC<HeaderProps> = ({ config }) => {
return (
<>
{config.clock.enabled && (
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
<Clock config={config} getClockSizeClass={getClockSizeClass} />
<Clock config={config} />
</div>
)}
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
+160 -30
View File
@@ -1,8 +1,12 @@
import { Config, Wallpaper } from '../../types';
import { Category, Config, Wallpaper } from '../../types';
import { DEFAULT_CATEGORIES } from '../../constants';
import { baseWallpapers } from '../utils/baseWallpapers';
import {
addWallpaperToChromeStorageLocal,
checkChromeStorageLocalAvailable,
removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager';
import { getFileNameFromUrl } from '../utils/urlUtils';
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
@@ -43,30 +47,133 @@ const safeParse = (value: string | null): unknown => {
const toStorageString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value);
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
const deepMerge = <T>(base: T, stored: unknown): T => {
if (isPlainObject(base)) {
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
const storedObj = isPlainObject(stored) ? stored : {};
for (const key of Object.keys(result)) {
result[key] = deepMerge(result[key], Object.prototype.hasOwnProperty.call(storedObj, key) ? storedObj[key] : undefined);
}
return result as T;
}
if (Array.isArray(base)) {
return [...(Array.isArray(stored) ? stored : base)] as T;
}
if (stored === null) return base;
return typeof stored === typeof base ? (stored as T) : base;
};
const boundedNumber = (value: number, fallback: number, min: number, max: number): number =>
Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
const choice = (value: string, values: string[], fallback: string): string =>
values.includes(value) ? value : fallback;
const hasStrings = (value: unknown, keys: string[]): value is Record<string, string> =>
isPlainObject(value) && keys.every(key => typeof value[key] === 'string');
export const normalizeConfig = (stored: unknown): Config => {
const config = deepMerge(DEFAULT_CONFIG, stored);
config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))];
config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50);
config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200);
config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100);
config.titleSize = choice(config.titleSize, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.titleSize);
config.tileSize = choice(config.tileSize || 'medium', ['small', 'medium', 'large'], 'medium');
config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment);
config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment);
config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size);
config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format);
config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60);
const serverIds = new Set<string>();
config.serverWidget.servers = config.serverWidget.servers.filter(server => {
if (!hasStrings(server, ['id', 'name', 'address']) || !server.id || serverIds.has(server.id)) return false;
serverIds.add(server.id);
return true;
}).map(server => ({ id: server.id, name: server.name, address: server.address }));
return config;
};
export const normalizeCategories = (stored: unknown): Category[] => {
if (!Array.isArray(stored)) return normalizeCategories(DEFAULT_CATEGORIES);
const categoryIds = new Set<string>();
const websiteIds = new Set<string>();
return stored.flatMap(category => {
if (!hasStrings(category, ['id', 'name']) || !category.id || categoryIds.has(category.id)) return [];
categoryIds.add(category.id);
const websites = isPlainObject(category) && Array.isArray(category.websites) ? category.websites : [];
return [{
id: category.id,
name: category.name,
websites: websites.flatMap(website => {
if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return [];
websiteIds.add(website.id);
return [{ id: website.id, name: website.name, url: website.url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
}),
}];
});
};
export const normalizeUserWallpapers = (stored: unknown): Wallpaper[] => {
if (!Array.isArray(stored)) return [];
const names = new Set(baseWallpapers.map(wallpaper => wallpaper.name));
return stored.flatMap(wallpaper => {
if (!hasStrings(wallpaper, ['name']) || !wallpaper.name || names.has(wallpaper.name)) return [];
names.add(wallpaper.name);
return [{
name: wallpaper.name,
...(typeof wallpaper.url === 'string' ? { url: wallpaper.url } : {}),
...(typeof wallpaper.base64 === 'string' ? { base64: wallpaper.base64 } : {}),
}];
});
};
const assertWallpaperNameAvailable = (name: string): void => {
const names = [...baseWallpapers, ...ConfigurationService.loadUserWallpapers()];
if (name.startsWith('vision-start:icon:') || names.some(wallpaper => wallpaper.name === name)) {
throw new Error('A wallpaper with this name already exists or the name is reserved. Please choose a different name.');
}
};
export const ConfigurationService = {
loadConfig(): Config {
try {
const stored = localStorage.getItem('config');
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_CONFIG, ...parsed };
return normalizeConfig(parsed);
}
} catch (error) {
console.error('Error parsing config from localStorage', error);
}
return { ...DEFAULT_CONFIG };
return normalizeConfig(null);
},
saveConfig(config: Config): void {
try {
localStorage.setItem('config', JSON.stringify(config));
} catch (error) {
console.error('Error saving config to localStorage', error);
}
},
loadCategories(): Category[] {
try {
return normalizeCategories(safeParse(localStorage.getItem('categories')));
} catch (error) {
console.error('Error reading categories from localStorage', error);
return normalizeCategories(null);
}
},
loadUserWallpapers(): Wallpaper[] {
try {
const stored = localStorage.getItem('userWallpapers');
if (stored) return JSON.parse(stored);
if (stored) return normalizeUserWallpapers(JSON.parse(stored));
} catch {
// ignore
}
return [];
},
@@ -76,11 +183,24 @@ export const ConfigurationService = {
},
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
const finalName = await addWallpaperToChromeStorageLocal(name, url);
return { name: finalName };
const trimmedUrl = url.trim();
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmedUrl);
} catch {
throw new Error('Please enter a valid wallpaper URL.');
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
}
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
assertWallpaperNameAvailable(finalName);
return { name: finalName, url: parsedUrl.href };
},
async addWallpaperFile(file: File): Promise<Wallpaper> {
if (!file.type.startsWith('image/')) throw new Error('Please choose an image file.');
assertWallpaperNameAvailable(file.name.trim());
return new Promise((resolve, reject) => {
if (file.size > 4 * 1024 * 1024) {
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
@@ -94,6 +214,7 @@ export const ConfigurationService = {
return;
}
try {
assertWallpaperNameAvailable(file.name.trim());
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
resolve({ name: finalName });
} catch (error) {
@@ -106,6 +227,7 @@ export const ConfigurationService = {
},
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
if (wallpaper.url || wallpaper.base64 || !checkChromeStorageLocalAvailable()) return;
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
},
@@ -135,6 +257,7 @@ export const ConfigurationService = {
},
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
if (file.size > 10 * 1024 * 1024) throw new Error('Configuration files must be no larger than 10MB.');
const fileContent = await file.text();
const parsed = JSON.parse(fileContent);
const localStorageData =
@@ -142,40 +265,47 @@ export const ConfigurationService = {
? parsed.localStorage
: parsed;
if (!localStorageData || typeof localStorageData !== 'object') {
if (!isPlainObject(localStorageData)) {
throw new Error('Invalid import file format.');
}
let importedAny = false;
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
const updates = new Map<RequiredLocalStorageKey, string>();
for (const key of REQUIRED_LOCAL_STORAGE_KEYS) {
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
const rawValue = (localStorageData as Record<string, unknown>)[key];
localStorage.setItem(key, toStorageString(rawValue));
importedAny = true;
const raw = localStorageData[key];
const value = typeof raw === 'string' ? safeParse(raw) : raw;
if (value === null) continue;
if ((key === 'config' || key === 'wallpaperState') && !isPlainObject(value)) {
throw new Error(`Invalid ${key} in import file.`);
}
if ((key === 'categories' || key === 'userWallpapers') && !Array.isArray(value)) {
throw new Error(`Invalid ${key} in import file.`);
}
const normalized = key === 'config' ? normalizeConfig(value)
: key === 'categories' ? normalizeCategories(value)
: key === 'userWallpapers' ? normalizeUserWallpapers(value) : value;
updates.set(key, toStorageString(normalized));
}
}
});
if (!importedAny) {
if (updates.size === 0) {
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
}
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
.userWallpapers as Wallpaper[];
const previous = new Map([...updates.keys()].map(key => [key, localStorage.getItem(key)]));
try {
for (const [key, value] of updates) localStorage.setItem(key, value);
} catch (error) {
for (const key of updates.keys()) localStorage.removeItem(key);
for (const [key, value] of previous) {
if (value !== null) localStorage.setItem(key, value);
}
throw error;
}
return {
config: importedConfig || { ...DEFAULT_CONFIG },
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
config: this.loadConfig(),
userWallpapers: this.loadUserWallpapers(),
};
},
resetWallpaperState(): void {
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: 0,
}),
);
},
};
+87 -90
View File
@@ -1,4 +1,6 @@
// TypeScript interface for window.chrome
import { getFileNameFromUrl } from './urlUtils';
declare global {
interface Window {
chrome?: {
@@ -18,6 +20,10 @@ declare global {
let isChromeStorageLocalAvailable: boolean | null = null;
const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
const getIconCacheKey = (sourceUrl: string): string =>
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
/**
* Checks if chrome.storage.local is available and caches the result.
@@ -32,85 +38,95 @@ export function checkChromeStorageLocalAvailable(): boolean {
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> {
if (!checkChromeStorageLocalAvailable()) return null;
try {
const key = getIconCacheKey(sourceUrl);
const result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([key], cb),
);
return result[key] || null;
} catch {
return null;
}
}
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
);
return true;
} catch {
return false;
}
}
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
try {
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
);
return true;
} catch {
return false;
}
}
/**
* Adds a new wallpaper to chrome.storage.local.
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL.
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
* File uploads are stored as base64 while remote wallpapers remain URLs.
* @param name Wallpaper name (string), used as a fallback.
* @param url Wallpaper image URL (string) or base64 data URL.
* @returns Promise<string> The name under which the wallpaper was stored.
* @throws Error if chrome.storage.local is unavailable or if a name is not provided for a non-fetchable URL.
* @throws Error if chrome.storage.local is unavailable or the wallpaper data is invalid.
*/
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
let finalName = name.trim();
if (url.startsWith('data:')) {
// This is a base64 encoded image from a file upload.
// The name is the file name.
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [name]: url }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
if (!finalName) throw new Error('A name is required for an uploaded wallpaper.');
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => name);
}
// This is a URL. Let's try to fetch it.
let parsedUrl: URL;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch image');
const imageBlob = await response.blob();
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
});
parsedUrl = new URL(url);
} catch {
throw new Error('Please enter a valid wallpaper URL.');
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
}
finalName = finalName || getFileNameFromUrl(parsedUrl);
}
// If successful, use the filename from URL as the name.
const finalName = url.substring(url.lastIndexOf('/') + 1).replace(/[?#].*$/, '') || name;
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [finalName]: base64 }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => finalName);
} catch (error) {
// If fetch fails (e.g., CORS), store the URL directly with the user-provided name.
console.warn('Could not fetch wallpaper, storing URL instead. Error:', error);
if (!name) {
throw new Error("A name for the wallpaper is required when the URL can't be accessed.");
}
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [name]: url }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => name);
}
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
);
return finalName;
}
/**
@@ -123,19 +139,10 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
return new Promise<string | null>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve(result[name] || null);
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
const result = await chromeLocalCall<StorageResult>((cb) =>
window.chrome?.storage?.local?.get([name], cb),
);
return typeof result[name] === 'string' ? result[name] || null : null;
}
/**
@@ -148,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.remove(name, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
await chromeLocalCall<void>((cb) =>
window.chrome?.storage?.local?.remove(name, cb),
);
}
+239 -20
View File
@@ -1,31 +1,250 @@
import {
checkChromeStorageLocalAvailable,
getCachedIconFromChromeStorageLocal,
removeCachedIconFromChromeStorageLocal,
saveCachedIconToChromeStorageLocal,
} from './StorageLocalManager';
async function getWebsiteIcon(url: string): Promise<string> {
const MAX_CACHED_ICON_BYTES = 256 * 1024;
const MAX_RESOLVED_ICONS = 50;
const MAX_RESOLVED_ICON_MEMORY = 2 * 1024 * 1024;
const MAX_CONCURRENT_ICON_REQUESTS = 4;
let resolvedIconBytes = 0;
let activeIconRequests = 0;
const iconRequestQueue: (() => void)[] = [];
const resolvedIconCache = new Map<string, string>();
const iconCacheLookups = new Map<string, Promise<string | null>>();
const iconCacheRequests = new Map<string, Promise<string | null>>();
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
resolvedIconBytes -= (resolvedIconCache.get(iconUrl)?.length ?? 0) * 2;
resolvedIconCache.delete(iconUrl);
resolvedIconCache.set(iconUrl, dataUrl);
resolvedIconBytes += dataUrl.length * 2;
while (resolvedIconCache.size > MAX_RESOLVED_ICONS || resolvedIconBytes > MAX_RESOLVED_ICON_MEMORY) {
const oldest = resolvedIconCache.keys().next().value;
if (oldest === undefined) break;
resolvedIconBytes -= (resolvedIconCache.get(oldest)?.length ?? 0) * 2;
resolvedIconCache.delete(oldest);
}
};
const isDataUrl = (value: string): boolean => value.startsWith('data:');
const isCacheableIconUrl = (value: string): boolean => {
try {
const response = await fetch(url);
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
const appleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]');
if (appleTouchIcon) {
const href = appleTouchIcon.getAttribute('href');
if (href) {
return new URL(href, url).href;
}
}
const isValidCachedIcon = (value: string | null): value is string =>
typeof value === 'string' && value.startsWith('data:image/') && value.length <= Math.ceil(MAX_CACHED_ICON_BYTES * 4 / 3) + 256;
const iconLink = doc.querySelector('link[rel="icon"][type="image/png"]') || doc.querySelector('link[rel="icon"]');
if (iconLink) {
const href = iconLink.getAttribute('href');
if (href) {
return new URL(href, url).href;
const readIconBlob = async (response: Response): Promise<Blob | null> => {
const contentType = (response.headers.get('content-type') || '').split(';', 1)[0].trim().toLowerCase();
if (!contentType.startsWith('image/') || Number(response.headers.get('content-length')) > MAX_CACHED_ICON_BYTES || !response.body) {
await response.body?.cancel();
return null;
}
const reader = response.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_CACHED_ICON_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value as Uint8Array<ArrayBuffer>);
}
return bytes > 0 ? new Blob(chunks, { type: contentType }) : null;
} finally {
reader.releaseLock();
}
};
} catch (error) {
console.error('Error fetching and parsing HTML for icon:', error);
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not convert icon to a data URL'));
}
};
reader.onerror = () => reject(reader.error || new Error('Could not read icon data'));
reader.readAsDataURL(blob);
});
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
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 {
const url = new URL(iconUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
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;
}
};
async function getWebsiteIcon(rawUrl: string): Promise<string> {
const trimmed = rawUrl.trim();
const hasProtocol = /^https?:\/\//i.test(trimmed);
const targetUrl = hasProtocol ? trimmed : `https://${trimmed}`;
try {
const parsed = new URL(targetUrl);
if (!isTrustedTld(parsed.hostname)) {
if (!hasProtocol) {
return `http://${parsed.host}/favicon.ico`;
}
return `${parsed.origin}/favicon.ico`;
}
return `https://www.google.com/s2/favicons?domain=${parsed.hostname}&sz=128`;
} catch {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
}
}
export { getWebsiteIcon };
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
if (isDataUrl(iconUrl)) return iconUrl;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
const existingLookup = iconCacheLookups.get(iconUrl);
if (existingLookup) return existingLookup;
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
.then((cachedIcon) => {
if (isValidCachedIcon(cachedIcon)) {
rememberResolvedIcon(iconUrl, cachedIcon);
return cachedIcon;
}
if (cachedIcon) void removeCachedIconFromChromeStorageLocal(iconUrl);
return null;
})
.catch(() => null)
.finally(() => {
iconCacheLookups.delete(iconUrl);
});
iconCacheLookups.set(iconUrl, lookup);
return lookup;
}
async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
const existingRequest = iconCacheRequests.get(iconUrl);
if (existingRequest) return existingRequest;
if (iconCacheRequests.size >= MAX_RESOLVED_ICONS) return null;
const request = (async () => {
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
if (cachedIcon) return cachedIcon;
const fetchSource = getIconFetchSource(iconUrl);
if (!fetchSource) return null;
if (activeIconRequests >= MAX_CONCURRENT_ICON_REQUESTS) {
await new Promise<void>(resolve => iconRequestQueue.push(resolve));
} else {
activeIconRequests++;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(fetchSource, { signal: controller.signal, credentials: 'omit' });
if (!response.ok || response.type === 'opaque') return null;
const blob = await readIconBlob(response);
if (!blob) return null;
const dataUrl = await blobToDataUrl(blob);
rememberResolvedIcon(iconUrl, dataUrl);
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
return dataUrl;
} catch {
return null;
} finally {
clearTimeout(timeout);
controller.abort();
const nextRequest = iconRequestQueue.shift();
if (nextRequest) nextRequest();
else activeIconRequests--;
}
})().finally(() => {
iconCacheRequests.delete(iconUrl);
});
iconCacheRequests.set(iconUrl, request);
return request;
}
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
resolvedIconBytes -= (resolvedIconCache.get(iconUrl)?.length ?? 0) * 2;
resolvedIconCache.delete(iconUrl);
iconCacheLookups.delete(iconUrl);
await removeCachedIconFromChromeStorageLocal(iconUrl);
}
export {
cacheWebsiteIcon,
getCachedWebsiteIcon,
getWebsiteIcon,
removeCachedWebsiteIcon,
};
+2
View File
@@ -0,0 +1,2 @@
export const createId = (): string =>
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
-38
View File
@@ -1,38 +0,0 @@
function request_image(url) {
return new Promise(function(resolve, reject) {
var img = new Image();
var settled = false;
function settleOk() {
if (settled) return;
settled = true;
clearTimeout(failTimer);
img.onload = img.onerror = null;
resolve(img);
}
function settleFail() {
if (settled) return;
settled = true;
clearTimeout(failTimer);
img.onload = img.onerror = null;
reject(url);
}
img.onload = settleOk;
img.onerror = settleFail;
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16);
var failTimer = setTimeout(settleFail, 5000);
});
}
function ping(url, multiplier) {
return new Promise(function(resolve, reject) {
var start = (new Date()).getTime();
var response = function() {
var delta = ((new Date()).getTime() - start);
delta *= (multiplier || 1);
resolve(delta);
};
request_image(url).then(response).catch(response);
});
}
export default ping;
+30
View File
@@ -0,0 +1,30 @@
async function ping(url: string, multiplier = 1, signal?: AbortSignal): Promise<number> {
const address = url.trim();
const target = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(address) ? address : `http://${address}`);
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
throw new Error('Server addresses must use HTTP or HTTPS.');
}
const controller = new AbortController();
const abort = () => controller.abort();
if (signal?.aborted) abort();
signal?.addEventListener('abort', abort, { once: true });
const timeout = setTimeout(abort, 5000);
const start = performance.now();
try {
await fetch(target.href, {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-store',
credentials: 'omit',
signal: controller.signal,
});
return (performance.now() - start) * multiplier;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
}
}
export default ping;
+64
View File
@@ -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';
+9
View File
@@ -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;
}
};
+55
View File
@@ -0,0 +1,55 @@
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(),
});
try {
if (localStorage.getItem('wallpaperState') !== payload) {
localStorage.setItem('wallpaperState', payload);
}
} catch (error) {
console.error('Error saving wallpaper state', error);
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

-2
View File
@@ -56,7 +56,6 @@ body {
background-size: cover;
background-position: center;
transform: scale(1.015);
will-change: filter, opacity, transform;
}
.wallpaper-luminance {
@@ -368,7 +367,6 @@ body {
background 260ms var(--ease-ios),
border-color 260ms var(--ease-ios),
box-shadow 340ms var(--ease-liquid);
will-change: transform, box-shadow;
}
.liquid-tile:hover {
+7 -2
View File
@@ -1,14 +1,19 @@
{
"manifest_version": 3,
"name": "Vision Startpage",
"version": "1.0",
"description": "A beautiful and customizable startpage for your browser.",
"version": "0.0.0",
"description": "A light, modern and customizable startpage.",
"chrome_url_overrides": {
"newtab": "index.html"
},
"permissions": [
"storage"
],
"icons": {
"16": "extension/icons/vision-16.png",
"48": "extension/icons/vision-48.png",
"128": "extension/icons/vision-128.png"
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self';"
}
+432 -243
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"capture:screenshots": "node scripts/capture_screenshots.mjs"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -20,6 +21,7 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21",
"playwright": "1.61.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"typescript": "~5.7.2",
+71 -36
View File
@@ -1,6 +1,6 @@
---
project_name: Vision Start
date: 2026-07-03
date: 2026-10-09
type: general_overview
---
@@ -40,6 +40,7 @@ Live instances / artifacts:
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
| Release screenshots | Playwright 1.61.1 + Chromium capture the deployed production page at a fixed 1280×800 viewport |
Entry points: `index.html` → `index.tsx` → `App.tsx`.
@@ -53,23 +54,28 @@ The startpage is composed of widgets and a configuration panel:
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
- **Title** — Optional big header title (text + size configurable).
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at a cadence (`1h`–`2d`).
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Probes use cancellable, credential-free HTTP HEAD requests with a 5s timeout. Online means the browser received a response; cross-origin HTTP status codes are opaque, so this is reachability rather than an HTTP health check. Network failures, timeouts, and browser-blocked requests appear offline.
- **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`).
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
- **Icon library & auto-fetch** — 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.
- 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 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).
- **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`.
Performance notes:
- 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.
- 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. `ServerWidgetTab` is independently lazy-loaded, so its `@hello-pangea/dnd` dependency is loaded only when the Server Widget settings tab is selected. `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.
- `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.
- `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, 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.
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
- `Clock` schedules one timeout for the next minute boundary, pauses while hidden, and refreshes immediately on focus or visibility return.
- `jsping` uses HTTP HEAD without downloading or decoding probe images, propagates failures, and cleans up its timeout and abort listener.
- `ServerWidget` runs at most four probes concurrently, batches completed results into one state update, avoids overlapping polling cycles, cancels work when hidden or unmounted, and depends on a stable ids/addresses signature. Hidden tabs stop polling and refresh on visibility return; removed servers leave no retained status entries.
- 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. Only names, file formats, and color variants are retained in component state; requests are aborted on close, failed loads can retry, and metadata URLs resolve relative to the page so subdirectory deployments work (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
- `Wallpaper` resolves remote wallpaper URLs through a module-level `Map` bounded to the last 3 entries. Uploaded data URLs are excluded from that cache, and the cache clears on unmount; 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 bounds its extra resolved data-URL cache to 50 entries and 2MiB of string storage (conservatively counted at two bytes per character). Cache downloads run at most four at a time, with at most 50 outstanding requests and an 8s timeout. Streaming reads stop at 256KiB before decoding; invalid or oversized persisted entries are discarded. Tile images still retain the data they display, separate from this extra cache.
- Tile icon dimensions use numeric inline styles, independent of generated Tailwind classes. Failed icons show the website initial, modified link clicks avoid navigation spinners, and browser history restoration clears the loading state. Category mutations preserve unaffected object references and use stable functional state handlers. Shared IDs use UUIDs with a fallback for ordinary HTTP deployments.
- Permanent `will-change` hints are removed from tiles and wallpaper so the browser chooses when to allocate compositing layers.
- 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`):
- 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.
@@ -88,7 +94,6 @@ vision-start/
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
├── constants.tsx # DEFAULT_CATEGORIES seed data
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
├── icon.png # Extension icon source
│
├── components/
│ ├── Clock.tsx # Header clock widget
@@ -97,9 +102,11 @@ vision-start/
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
│ ├── CategoryEditModal.tsx # Add/edit a category
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
│ ├── ModalShell.tsx # Shared centered-modal shell (backdrop, title, footer buttons)
│ ├── ServerWidget.tsx # Bottom server status pill
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
│ ├── ToggleSwitch.tsx # Reusable toggle switch
│ ├── icons.tsx # Shared inline SVG icons (pencil, plus, trash, chevrons)
│ │
│ ├── layout/
│ │ ├── Header.tsx # Renders Clock + Title
@@ -109,30 +116,39 @@ vision-start/
│ │
│ ├── configuration/
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
│ │ ├── 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/
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
│ │
│ └── utils/
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
│ ├── iconService.ts # getWebsiteIcon: fetch HTML, parse apple-touch-icon/icon, fallback to Google favicons
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper fetch/base64/URL storage
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
│ ├── jsping.ts # Cancellable HTTP HEAD reachability probe with 5s timeout
│ ├── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
│ ├── idUtils.ts # Shared UUID generation with an ordinary-HTTP fallback
│ ├── 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/
│ ├── favicon.ico
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
│
├── screenshots/ # README screenshots (dark page, editing, configuration)
├── screenshots/ # README/release screenshots (home, editing, configuration; regenerated at 1280×800)
├── scripts/
│ ├── check_regressions.mjs # Persistence/network regressions, browser memory checks, unpacked-extension smoke test
│ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL
│ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
│
├── .gitea/workflows/
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
│ ├── pull-request.yaml # On PR updates: build/zip, capture screenshots, and publish an inline visual preview
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
│
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
@@ -140,7 +156,7 @@ vision-start/
├── .dockerignore
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
├── tailwind.config.js # Content globs + safelist of dynamic w-/h- sizes
├── tailwind.config.js # Content globs + legacy pixel-size safelist
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
├── package.json # Scripts: dev, build, preview
@@ -152,15 +168,17 @@ vision-start/
## 5. Data Model & State
The icon service also resolves website icon URLs through the persistent CORS-safe cache used by `WebsiteTile`; this cache is separate from the `Website` data model.
The shape of all persisted data lives in `types.ts`:
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
- **`Server`** — `id`, `name`, `address`
- **`Category`** — `id`, `name`, `websites: Website[]`
- **`Wallpaper`** — `name`, optional `url` or `base64`
- **`Config`** — Everything else: title, wallpaper list + frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
- **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers.
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. Nested defaults are cloned, option values and numeric ranges are validated, and malformed wallpaper/server entries are filtered. Categories and the user wallpaper index are normalized before rendering; website category IDs are repaired to match their containing category. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
Storage layout (browser-side):
@@ -168,18 +186,19 @@ Storage layout (browser-side):
|---|---|---|
| `config` | `localStorage` | The full `Config` JSON |
| `categories` | `localStorage` | `Category[]` JSON |
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data |
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
| `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 |
| `vision-start:icon:<encoded-source-url>` | `chrome.storage.local` (when available) | CORS-readable website icon data URL, capped at 256KiB |
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`.
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`. Remote wallpaper URLs are included through `userWallpapers`; uploaded image data and rebuildable icon cache entries are not included.
---
## 6. Application Flow (high level)
1. `index.html` loads `index.tsx`, which mounts `<App/>` into `#root`.
2. `App.tsx` initializes state from `localStorage` (`categories`) and `ConfigurationService.loadConfig()` (`config`), falling back to defaults.
2. `App.tsx` initializes normalized categories and configuration through `ConfigurationService`, falling back to independent copies of defaults.
3. `useEffect` hooks persist `config` and `categories` back to `localStorage` whenever they change.
4. The screen renders:
- `<Wallpaper>` behind everything (fetches URL/base64 from base catalog or chrome.storage.local; rotates per frequency).
@@ -188,7 +207,8 @@ Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaper
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
- Optional `<ServerWidget>` if enabled.
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
5. Each `<WebsiteTile>` checks the icon cache before loading the external URL, then asynchronously populates the cache on a miss.
6. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
---
@@ -200,8 +220,14 @@ npm install
npm run dev # Vite dev server (note: PROJECT.md says prefer `npm run build` for real testing)
npm run build # Production build → dist/
npm run preview # Serve built dist/
npx tsc --noEmit # Strict TypeScript check
npx playwright install chromium
node scripts/check_regressions.mjs # Run after building; isolated fixtures and temporary extension profile
npm audit # Audit the locked dependency tree
```
The regression script covers malformed persisted data, partial/legacy imports and quota rollback, wallpaper validation, server probe failure/timeout/cancellation, icon request concurrency/stream limits/cache eviction, hidden-tab clock and polling behavior, modal request cancellation and retained JS heap after repeated picker cycles, lazy settings chunks, and loading an unpacked Manifest V3 extension in an isolated Chromium profile. It uses existing Vite/Playwright dependencies and does not modify the normal browser profile or shipped metadata.
### Chrome extension install (manual)
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
@@ -209,6 +235,9 @@ 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.
### 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. 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 with `dist/` contents, `manifest.json`, and `extension/` at its root, then unpacks that archive into `dist/` 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.
- **`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`.
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
@@ -221,13 +250,16 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
## 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.
- **`@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. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. 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 "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
- **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.
- **`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 is independently lazy-loaded by `ConfigurationModal`, so opening General/Theme/Clock settings does not load the drag-and-drop chunk. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
- **Import validation** accepts both export bundles and legacy objects containing serialized storage values. Files larger than 10MB are rejected before reading. Required keys are normalized and validated before writes; storage failures clear affected keys before restoring previous values so rollback also works near the quota limit. Omitted keys retain their existing settings. Importing marks the settings drawer as saved, preventing its cancel cleanup from restoring pre-import wallpapers.
- **Wallpaper mutations** reject duplicate/built-in/reserved names and non-image file uploads. The settings drawer serializes wallpaper mutations and reads the latest persisted index, only updates its list after persistence succeeds, and cancel does not reselect deleted wallpapers.
- **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** 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 page-relative `icon-metadata.json` on focus, validates the response, and lazily expands color variants into up to 50 searchable results. Closing it cancels metadata fetching and filtering timers.
- **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.
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
- **Tile/icon sizing** uses static tile classes and numeric inline icon dimensions. The legacy pixel-size safelist remains in `tailwind.config.js`, but tile icons no longer depend on it.
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
@@ -244,13 +276,16 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
| Main app wiring (state, handlers, layout) | `App.tsx` |
| Settings UI | `components/ConfigurationModal.tsx` + `components/configuration/*Tab.tsx` |
| Wallpaper rendering/rotation | `components/Wallpaper.tsx` |
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.ts` |
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
| Website icon cache | `components/utils/iconService.ts`, `components/WebsiteTile.tsx`, `components/utils/StorageLocalManager.ts` |
| Regression and memory checks | `scripts/check_regressions.mjs` (run after `npm run build`) |
| Shared IDs | `components/utils/idUtils.ts` |
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
| Release packaging | `scripts/prepare_release.sh`, `scripts/check_virustotal.sh`, `.gitea/workflows/release.yaml` |
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
| Docker build | `Dockerfile` |
---
_Last updated: 2026-07-03. Generated as a general project overview; not a coding-style guide._
_Last updated: 2026-10-09. Generated as a general project overview; not a coding-style guide._
Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

+190
View File
@@ -0,0 +1,190 @@
import { spawn } from 'node:child_process';
import { mkdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { chromium } from 'playwright';
const viewport = { width: 1280, height: 800 };
const host = '127.0.0.1';
const port = 4173;
const configuredBaseUrl = process.env.SCREENSHOT_BASE_URL?.replace(/\/+$/, '');
const baseUrl = configuredBaseUrl || `http://${host}:${port}`;
const outputDirectory = process.env.SCREENSHOT_OUTPUT_DIR || 'screenshots';
const demoDataPath = process.env.SCREENSHOT_DEMO_DATA || 'scripts/demoData.json';
const viteCli = path.resolve('node_modules/vite/bin/vite.js');
const imgurImageCache = new Map();
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const waitForServer = async (preview) => {
const deadline = Date.now() + 30_000;
let lastError;
while (Date.now() < deadline) {
if (previewError) throw previewError;
if (preview && preview.exitCode !== null) {
throw new Error(`Vite preview exited with code ${preview.exitCode}.`);
}
try {
const response = await fetch(baseUrl);
if (response.ok) return;
lastError = new Error(`Vite preview returned ${response.status}.`);
} catch (error) {
lastError = error;
}
await delay(250);
}
throw new Error(`Screenshot target did not respond at ${baseUrl}. ${lastError?.message || ''}`.trim());
};
const loadDemoData = async () => {
const encodedData = JSON.parse(await readFile(demoDataPath, 'utf8'));
if (!encodedData || typeof encodedData !== 'object' || Array.isArray(encodedData)) {
throw new Error(`${demoDataPath} must contain an object of base64-encoded localStorage values.`);
}
return Object.entries(encodedData).map(([key, value]) => {
if (typeof value !== 'string') {
throw new Error(`${demoDataPath} contains a non-string value for ${key}.`);
}
return { key, value: Buffer.from(value, 'base64').toString('utf8') };
});
};
const fulfillImgurRequest = async (route) => {
const url = route.request().url();
let image = imgurImageCache.get(url);
if (!image) {
try {
const response = await fetch(url);
if (!response.ok) {
await route.continue();
return;
}
image = {
body: Buffer.from(await response.arrayBuffer()),
contentType: response.headers.get('content-type') || 'image/jpeg',
};
imgurImageCache.set(url, image);
} catch {
await route.continue();
return;
}
}
await route.fulfill({
status: 200,
headers: { 'content-type': image.contentType },
body: image.body,
});
};
const stopPreview = async (preview) => {
if (preview.exitCode !== null) return;
const exited = new Promise((resolve) => preview.once('exit', resolve));
preview.kill('SIGTERM');
await Promise.race([exited, delay(5_000)]);
if (preview.exitCode === null) preview.kill('SIGKILL');
};
const assertPngDimensions = (image, filename) => {
const width = image.readUInt32BE(16);
const height = image.readUInt32BE(20);
if (width !== viewport.width || height !== viewport.height) {
throw new Error(`${filename} was generated at ${width}x${height}, expected ${viewport.width}x${viewport.height}.`);
}
};
const capture = async (page, filename) => {
const image = await page.screenshot({
path: path.join(outputDirectory, filename),
type: 'png',
fullPage: false,
scale: 'css',
animations: 'disabled',
});
assertPngDimensions(image, filename);
};
const loadPage = async (page) => {
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
await page.locator('main').waitFor({ state: 'visible' });
await page.evaluate(() => document.fonts.ready);
await delay(1_500);
};
let previewError;
const preview = configuredBaseUrl
? undefined
: spawn(
process.execPath,
[viteCli, 'preview', '--host', host, '--port', String(port), '--strictPort'],
{ stdio: 'inherit' },
);
if (preview) {
preview.once('error', (error) => {
previewError = error;
});
}
try {
await waitForServer(preview);
await mkdir(outputDirectory, { recursive: true });
const demoData = await loadDemoData();
const browser = await chromium.launch();
const context = await browser.newContext({
viewport,
screen: viewport,
deviceScaleFactor: 1,
locale: 'en-US',
timezoneId: 'America/Sao_Paulo',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
});
await context.route('https://i.imgur.com/**', fulfillImgurRequest);
await context.addInitScript((storageItems) => {
storageItems.forEach(({ key, value }) => localStorage.setItem(key, value));
}, demoData);
const page = await context.newPage();
try {
await loadPage(page);
await capture(page, 'home.png');
await page.getByRole('button', { name: 'Edit page' }).click();
await capture(page, 'editing.png');
await loadPage(page);
await page.getByRole('button', { name: 'Open configuration' }).click();
await page.getByRole('dialog').waitFor({ state: 'visible' });
await page.waitForFunction(
() => {
const drawer = document.querySelector('.liquid-drawer');
if (!drawer) return false;
const { left, right } = drawer.getBoundingClientRect();
return left < window.innerWidth && right <= window.innerWidth;
},
undefined,
{ timeout: 5_000 },
);
await capture(page, 'configuration.png');
} finally {
await context.close();
await browser.close();
}
} finally {
if (preview) await stopPreview(preview);
}
+325
View File
@@ -0,0 +1,325 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { resolve } from 'node:path';
import { mkdtemp, cp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { build, preview } from 'vite';
import { chromium } from 'playwright';
const loadModule = async (entry) => {
const result = await build({
configFile: false,
logLevel: 'silent',
build: {
write: false,
minify: false,
lib: { entry: resolve(entry), formats: ['es'] },
},
});
const output = Array.isArray(result) ? result[0] : result;
const code = output.output.find(chunk => chunk.type === 'chunk' && chunk.isEntry).code;
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`);
};
const storage = new Map();
const chromeStorage = new Map();
let failWrite = null;
let maximumStorageBytes = Infinity;
let chromeReads = 0;
globalThis.localStorage = {
getItem: key => storage.get(key) ?? null,
setItem: (key, value) => {
if (key === failWrite) {
failWrite = null;
throw new Error('Quota exceeded');
}
const next = new Map(storage).set(key, value);
if ([...next.values()].reduce((bytes, entry) => bytes + entry.length, 0) > maximumStorageBytes) throw new Error('Quota exceeded');
storage.set(key, value);
},
removeItem: key => storage.delete(key),
};
globalThis.window = { chrome: { storage: { local: {
get: (keys, callback) => {
chromeReads++;
callback(Object.fromEntries(keys.map(key => [key, chromeStorage.get(key)])));
},
set: (values, callback) => {
Object.entries(values).forEach(([key, value]) => chromeStorage.set(key, value));
callback();
},
remove: (key, callback) => { chromeStorage.delete(key); callback(); },
} } } };
globalThis.FileReader = class {
readAsDataURL(blob) {
blob.arrayBuffer().then(buffer => {
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
this.onloadend?.();
this.onload?.();
});
}
};
const [{ ConfigurationService, DEFAULT_CONFIG, normalizeConfig, normalizeCategories }, icons, { default: ping }] = await Promise.all([
loadModule('components/services/ConfigurationService.ts'),
loadModule('components/utils/iconService.ts'),
loadModule('components/utils/jsping.ts'),
]);
const file = value => new File([JSON.stringify(value)], 'config.json', { type: 'application/json' });
const iconUrl = name => `https://www.google.com/s2/favicons?domain=${name}.com&sz=128`;
const originalFetch = globalThis.fetch;
await test('malformed stored data recovers without shared defaults or unsafe polling rates', () => {
const config = normalizeConfig({ currentWallpapers: [null, 'Beach', 'Beach'], serverWidget: { pingFrequency: -1, servers: [null, {}, { id: '1', name: 'Server', address: 'localhost' }] }, clock: { size: 'unknown' }, wallpaperBlur: Infinity });
assert.deepEqual(config.currentWallpapers, ['Beach']);
assert.equal(config.serverWidget.pingFrequency, 5);
assert.equal(config.serverWidget.servers.length, 1);
assert.equal(config.clock.size, 'medium');
assert.equal(config.wallpaperBlur, 0);
const defaults = normalizeConfig(null);
defaults.clock.enabled = false;
defaults.currentWallpapers.length = 0;
assert.equal(DEFAULT_CONFIG.clock.enabled, true);
assert.deepEqual(DEFAULT_CONFIG.currentWallpapers, ['Beach']);
storage.set('categories', 'null');
assert.equal(ConfigurationService.loadCategories()[0].name, 'Search');
assert.deepEqual(normalizeCategories([]), []);
assert.deepEqual(normalizeCategories([null, { id: 'c', name: 'Group', websites: [null, { id: 'w', name: 'Site', url: 'https://example.com', categoryId: 'wrong' }] }])[0].websites[0].categoryId, 'c');
storage.set('userWallpapers', '{}');
assert.deepEqual(ConfigurationService.loadUserWallpapers(), []);
});
await test('imports validate before writing, roll back failures, and preserve omitted settings', async () => {
await assert.rejects(ConfigurationService.importConfig({ size: 10 * 1024 * 1024 + 1, text: () => assert.fail('Oversized files must not be read into memory') }), /10MB/);
storage.clear();
storage.set('config', JSON.stringify({ title: 'Original' }));
storage.set('categories', '[]');
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Bad' }, categories: {} })), /Invalid categories/);
assert.equal(JSON.parse(storage.get('config')).title, 'Original');
failWrite = 'categories';
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Bad' }, categories: [] })), /Quota/);
assert.equal(JSON.parse(storage.get('config')).title, 'Original');
const partial = await ConfigurationService.importConfig(file({ categories: '[]' }));
assert.equal(partial.config.title, 'Original');
const legacy = await ConfigurationService.importConfig(file({ config: JSON.stringify({ title: 'Legacy', clock: { enabled: false } }) }));
assert.equal(legacy.config.title, 'Legacy');
assert.equal(legacy.config.clock.enabled, false);
assert.equal(legacy.config.clock.size, 'medium');
storage.set('config', JSON.stringify(normalizeConfig({ title: 'Original'.repeat(50) })));
storage.set('userWallpapers', '[]');
const previous = new Map(storage);
maximumStorageBytes = [...storage.values()].reduce((bytes, value) => bytes + value.length, 0) + 32;
try {
await assert.rejects(ConfigurationService.importConfig(file({ config: { title: 'Small' }, categories: [{ id: 'c', name: 'Group'.repeat(40), websites: [] }], userWallpapers: [{ name: 'Large', url: `https://example.com/${'image'.repeat(200)}` }] })), /Quota/);
assert.deepEqual(storage, previous);
} finally { maximumStorageBytes = Infinity; }
});
await test('wallpaper collisions and non-image uploads cannot overwrite storage', async () => {
await assert.rejects(ConfigurationService.addWallpaper('Beach', 'https://example.com/image.png'), /already exists/);
await assert.rejects(ConfigurationService.addWallpaper('vision-start:icon:reserved', 'https://example.com/image.png'), /reserved/);
await assert.rejects(ConfigurationService.addWallpaperFile(new File(['text'], 'image.png', { type: 'text/plain' })), /image file/);
});
await test('server probes reject network failures, timeouts, and cancellation', async () => {
try {
globalThis.fetch = async (_url, options) => {
assert.equal(options.method, 'HEAD');
assert.equal(options.credentials, 'omit');
return new Response(null);
};
assert.equal(typeof await ping('localhost:8080'), 'number');
globalThis.fetch = async () => { throw new Error('Network failure'); };
await assert.rejects(ping('https://offline.example'), /Network failure/);
await assert.rejects(ping('ftp://example.com'), /HTTP or HTTPS/);
globalThis.fetch = async (_url, { signal }) => new Promise((_resolve, reject) => {
if (signal.aborted) reject(new Error('Aborted'));
else signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
});
const controller = new AbortController();
const request = ping('https://slow.example', 1, controller.signal);
controller.abort();
await assert.rejects(request, /Aborted/);
const originalTimeout = globalThis.setTimeout;
globalThis.setTimeout = callback => { queueMicrotask(callback); return 0; };
try { await assert.rejects(ping('https://slow.example'), /Aborted/); }
finally { globalThis.setTimeout = originalTimeout; }
} finally { globalThis.fetch = originalFetch; }
});
await test('icon requests deduplicate, cap concurrency, reject oversized streams, and evict by bytes', async () => {
let active = 0;
let maximum = 0;
let requests = 0;
try {
globalThis.fetch = async () => {
requests++;
maximum = Math.max(maximum, ++active);
await new Promise(resolve => setTimeout(resolve, 5));
active--;
return new Response(new Uint8Array(200 * 1024), { headers: { 'content-type': 'image/png' } });
};
const results = await Promise.all(Array.from({ length: 12 }, (_, index) => icons.cacheWebsiteIcon(iconUrl(`site${index}`))));
assert(results.every(result => result?.startsWith('data:image/png')));
assert(maximum <= 4);
const reads = chromeReads;
await icons.getCachedWebsiteIcon(iconUrl('site0'));
assert(chromeReads > reads, 'The oldest large icon must leave the extra memory cache');
const before = requests;
await Promise.all([icons.cacheWebsiteIcon(iconUrl('shared')), icons.cacheWebsiteIcon(iconUrl('shared'))]);
assert.equal(requests - before, 1);
let cancelled = false;
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.enqueue(new Uint8Array(128 * 1024)); },
cancel() { cancelled = true; },
}), { headers: { 'content-type': 'image/png' } });
assert.equal(await icons.cacheWebsiteIcon(iconUrl('oversized')), null);
assert.equal(cancelled, true);
} finally { globalThis.fetch = originalFetch; }
});
await test('production UI survives malformed data, distinguishes server status, and releases modal memory', async () => {
const server = await preview({ logLevel: 'silent', preview: { host: '127.0.0.1', port: 0, strictPort: true } });
const browser = await chromium.launch();
try {
const address = server.httpServer.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
const context = await browser.newContext();
const errors = [];
const page = await context.newPage();
await page.clock.install();
page.on('pageerror', error => errors.push(error.message));
await context.addInitScript(() => {
localStorage.setItem('config', JSON.stringify({ currentWallpapers: [], serverWidget: { enabled: true, pingFrequency: 5, servers: [null, { id: 'on', name: 'Online', address: 'https://online.example' }, { id: 'off', name: 'Offline', address: 'https://offline.example' }] } }));
localStorage.setItem('categories', JSON.stringify([null, { id: 'c', name: 'Group', websites: [{ id: 'a', name: 'Alpha', url: 'https://example.com', icon: '' }, { id: 'b', name: 'Beta', url: 'https://example.org', icon: '' }] }]));
localStorage.setItem('userWallpapers', 'null');
});
let onlineProbes = 0;
await context.route('https://online.example/**', route => {
onlineProbes++;
return route.fulfill({ status: 200, body: '' });
});
await context.route('https://offline.example/**', route => route.abort('connectionfailed'));
const metadata = Object.fromEntries(Array.from({ length: 4000 }, (_, index) => [`service-${index}`, { base: 'svg', colors: { blue: `service-${index}-blue` }, aliases: ['unused'.repeat(100)], update: { author: { name: 'unused'.repeat(100) } } }]));
let metadataLoads = 0;
let delayMetadata = false;
let releaseMetadata = () => {};
await context.route('**/icon-metadata.json', async route => {
metadataLoads++;
if (delayMetadata) await new Promise(resolve => { releaseMetadata = resolve; });
return route.fulfill({ json: metadata });
});
await context.route('https://cdn.jsdelivr.net/**', route => route.abort());
await page.goto(baseUrl);
await page.getByRole('link', { name: 'Open Alpha' }).waitFor();
await page.waitForFunction(() => [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-green-400')) && [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-red-400')));
const beforeHidden = onlineProbes;
const clockBeforeHidden = await page.locator('.liquid-clock-text').textContent();
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.clock.fastForward(120_000);
assert.equal(onlineProbes, beforeHidden);
assert.equal(await page.locator('.liquid-clock-text').textContent(), clockBeforeHidden);
const resumedProbe = page.waitForRequest('https://online.example/');
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
});
await resumedProbe;
await page.waitForFunction(before => document.querySelector('.liquid-clock-text').textContent !== before, clockBeforeHidden);
await page.waitForFunction(() => [...document.querySelectorAll('.liquid-status-dot')].some(dot => dot.classList.contains('bg-green-400')));
const downloaded = [];
page.on('request', request => downloaded.push(request.url()));
await page.getByRole('button', { name: 'Open configuration' }).click();
await page.getByRole('dialog').waitFor();
assert(!downloaded.some(url => url.includes('ServerWidgetTab.js')));
await page.getByRole('button', { name: 'Server Widget', exact: true }).click();
await page.getByPlaceholder('Server Name').waitFor();
assert(downloaded.some(url => url.includes('ServerWidgetTab.js')));
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('dialog').waitFor({ state: 'hidden' });
await page.getByRole('button', { name: 'Edit page' }).click();
await page.getByRole('button', { name: 'Move Beta left' }).click();
assert.deepEqual(await page.locator('a[aria-label$="edit controls"]').evaluateAll(links => links.map(link => [...link.querySelectorAll('span')].at(-1).textContent)), ['Beta', 'Alpha']);
const openPicker = async () => {
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
const input = page.getByPlaceholder('Icon URL or name');
await input.fill('service');
await input.focus();
await page.locator('.liquid-dropdown-list').waitFor();
assert.equal(await page.locator('.liquid-dropdown-list img').count(), 50);
await page.getByRole('button', { name: 'Close', exact: true }).click();
await page.getByPlaceholder('Icon URL or name').waitFor({ state: 'hidden' });
};
await openPicker();
const session = await context.newCDPSession(page);
const heap = async () => {
await session.send('HeapProfiler.collectGarbage');
return (await session.send('Runtime.getHeapUsage')).usedSize;
};
const before = await heap();
for (let cycle = 0; cycle < 15; cycle++) await openPicker();
const growth = (await heap()) - before;
assert(growth < 2 * 1024 * 1024, `Retained JS heap grew by ${growth} bytes`);
assert.equal(metadataLoads, 16);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = (url, options) => {
if (String(url).endsWith('/icon-metadata.json')) window.metadataSignal = options.signal;
return original(url, options);
};
});
delayMetadata = true;
await page.getByRole('button', { name: 'Edit Alpha', exact: true }).click();
const pendingMetadata = page.waitForRequest('**/icon-metadata.json');
await page.getByPlaceholder('Icon URL or name').focus();
await pendingMetadata;
await page.getByRole('button', { name: 'Close', exact: true }).click();
await page.waitForFunction(() => window.metadataSignal?.aborted === true);
releaseMetadata();
delayMetadata = false;
await openPicker();
assert.equal(metadataLoads, 18);
assert.deepEqual(errors, []);
console.log(`Retained JS heap change after 15 picker cycles: ${Math.round(growth / 1024)} KiB`);
await context.close();
} finally {
await browser.close();
await new Promise(resolve => server.httpServer.close(resolve));
}
});
await test('unpacked MV3 extension loads its new-tab page with working chrome storage', async () => {
const temporary = await mkdtemp(resolve(tmpdir(), 'vision-start-check-'));
const extension = resolve(temporary, 'extension');
let context;
try {
await cp(resolve('dist'), extension, { recursive: true });
await cp(resolve('manifest.json'), resolve(extension, 'manifest.json'));
await cp(resolve('extension'), resolve(extension, 'extension'), { recursive: true });
context = await chromium.launchPersistentContext(resolve(temporary, 'profile'), {
channel: 'chromium',
headless: true,
args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
});
await context.route(/^https?:/, route => route.abort());
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto('chrome://newtab');
await page.getByRole('button', { name: 'Open configuration' }).waitFor();
const origin = await page.evaluate(() => document.location.origin);
assert(origin.startsWith('chrome-extension://'), `Unexpected new-tab origin: ${origin}`);
assert.equal(await page.evaluate(async () => {
await chrome.storage.local.set({ regression: 'working' });
return (await chrome.storage.local.get('regression')).regression;
}), 'working');
await page.getByRole('button', { name: 'Open configuration' }).click();
await page.getByRole('dialog').waitFor();
assert.deepEqual(errors, []);
} finally {
await context?.close();
await rm(temporary, { recursive: true, force: true });
}
});
+6
View File
@@ -0,0 +1,6 @@
{
"categories": "W3siaWQiOiIxIiwibmFtZSI6IlNlYXJjaCIsIndlYnNpdGVzIjpbeyJpZCI6IjEiLCJuYW1lIjoiR29vZ2xlIiwidXJsIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbSIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1nb29nbGUuY29tJnN6PTEyOCIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQxODAxMDgiLCJuYW1lIjoiWW91VHViZSIsInVybCI6Imh0dHBzOi8vd3d3LnlvdXR1YmUuY29tLyIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy95b3V0dWJlLnN2ZyIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQyMjI2OTQiLCJuYW1lIjoiRHJpdmUiLCJ1cmwiOiJodHRwczovL2RyaXZlLmdvb2dsZS5jb20vZHJpdmUvdS8wL215LWRyaXZlIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dvb2dsZS1kcml2ZS5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDE0ODIwIiwibmFtZSI6IkdtYWlsIiwidXJsIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC91LzAvP3RhYj13bSNpbmJveCIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9nbWFpbC5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDI3NTYwIiwibmFtZSI6Ikxhc3QuZm0iLCJ1cmwiOiJodHRwczovL3d3dy5sYXN0LmZtL2hvbWUiLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49d3d3Lmxhc3QuZm0mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjEifV19LHsiaWQiOiIxNzUzMDQ0NDQ2NzU1IiwibmFtZSI6IkNvZGUiLCJ3ZWJzaXRlcyI6W3siaWQiOiIxNzUzMDQ0NDU5MjcyIiwibmFtZSI6IkdpdGh1YiIsInVybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9mZWVkIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dpdGh1Yi1saWdodC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4ODcwNjgzIiwibmFtZSI6IkFXUyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9hd3MtbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ0NDY3NTUifSx7ImlkIjoiMTc4MzcyODkxNTg1MyIsIm5hbWUiOiJEb2NrZXIgSHViIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2RvY2tlci5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4OTkyMzkwIiwibmFtZSI6IkZpZ21hIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2ZpZ21hLnN2ZyIsImNhdGVnb3J5SWQiOiIxNzUzMDQ0NDQ2NzU1In1dfSx7ImlkIjoiMTc1MzA0NDY2ODAxMiIsIm5hbWUiOiJNZWRpYSIsIndlYnNpdGVzIjpbeyJpZCI6IjE3NTMwNDQ3MDYwMjQiLCJuYW1lIjoiTmV0ZmxpeCIsInVybCI6Imh0dHA6Ly90di5oYXZlbi8iLCJpY29uIjoiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2hvbWFyci1sYWJzL2Rhc2hib2FyZC1pY29ucy9zdmcvbmV0ZmxpeC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzUzMDQ0NzQwOTg2IiwibmFtZSI6IlByaW1lIiwidXJsIjoiaHR0cDovL29tdi5oYXZlbiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9wcmltZS12aWRlby1hbHQtZGFyay5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzgzNzI4Nzg0MjU2IiwibmFtZSI6IkhCTyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9oYm8tbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc1MzA0NDgyODg0MCIsIm5hbWUiOiJUcmFrdCIsInVybCI6Imh0dHBzOi8vYXBwLnRyYWt0LnR2LyIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1hcHAudHJha3QudHYmc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc4MzcyODcyOTQyNCIsIm5hbWUiOiJMZXR0ZXJib3hkIiwidXJsIjoiaHR0cHM6Ly9sZXR0ZXJib3hkLmNvbS8iLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49bGV0dGVyYm94ZC5jb20mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifV19XQ==",
"config": "eyJ0aXRsZSI6IiIsImN1cnJlbnRXYWxscGFwZXJzIjpbIkFic3RyYWN0IFJlZCJdLCJ3YWxscGFwZXJGcmVxdWVuY3kiOiIxZCIsIndhbGxwYXBlckJsdXIiOjAsIndhbGxwYXBlckJyaWdodG5lc3MiOjEwOCwid2FsbHBhcGVyT3BhY2l0eSI6MTAwLCJ0aXRsZVNpemUiOiJtZWRpdW0iLCJhbGlnbm1lbnQiOiJtaWRkbGUiLCJob3Jpem9udGFsQWxpZ25tZW50IjoibWlkZGxlIiwidGlsZVNpemUiOiJzbWFsbCIsImNsb2NrIjp7ImVuYWJsZWQiOnRydWUsInNpemUiOiJ0aW55IiwiZm9udCI6Im1vbm9zcGFjZSIsImZvcm1hdCI6Img6bW0gQSJ9LCJzZXJ2ZXJXaWRnZXQiOnsiZW5hYmxlZCI6dHJ1ZSwicGluZ0ZyZXF1ZW5jeSI6MTUsInNlcnZlcnMiOlt7ImlkIjoiMTc4MzcyOTE4MzU1MyIsIm5hbWUiOiJBZEd1YXJkIiwiYWRkcmVzcyI6Imh0dHBzOi8vZ29vZ2xlLmNvbSJ9LHsiaWQiOiIxNzgzNzI5MjI3NDQyIiwibmFtZSI6IlByb3htb3giLCJhZGRyZXNzIjoiaHR0cHM6Ly9nb29nbGUuY29tIn1dfX0=",
"userWallpapers": "W3sibmFtZSI6ImRhcmstYWJzdHJhY3QtMjU2MHgxNDQwLWNvbnRlbXBvcmFyeS1kZXNpZ24tc2xlZWstbGluZXMtMjY0MjYuanBnIn1d",
"wallpaperState": "eyJsYXN0V2FsbHBhcGVyQ2hhbmdlIjoiMjA0Ni0wNy0xMVQwMDoyMzozMS4zODdaIiwiY3VycmVudEluZGV4IjowfQ=="
}