Compare commits
17
Commits
b60c88e9b1
..
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551bff1e5b | ||
|
|
d391dc7135 | ||
|
|
4885fd68c1 | ||
|
|
31de8265d9 | ||
|
|
e08853fe54 | ||
|
|
acd8369285 | ||
|
|
0ecf0eed62 | ||
|
|
7635471ed6 | ||
|
|
8ef643645a | ||
|
|
552379b2a6 | ||
|
|
f9864072cf | ||
|
|
51341c33ca | ||
|
|
8b5c52dd1e | ||
|
|
c3addb6d02 | ||
|
|
5597afc572 | ||
|
|
30372f800c | ||
|
|
9e738cc0d5 |
@@ -20,11 +20,17 @@ jobs:
|
||||
- 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: |
|
||||
@@ -33,20 +39,30 @@ jobs:
|
||||
|
||||
- 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 ../vision-start-${{ gitea.ref_name }}.zip *
|
||||
zip -r "../${ARCHIVE_NAME}" *
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-zip
|
||||
path: vision-start-${{ gitea.ref_name }}.zip
|
||||
path: ${{ env.ARCHIVE_NAME }}
|
||||
|
||||
build_vision_start:
|
||||
name: Build Vision Start Image
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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
|
||||
mv dist vision-start/
|
||||
mv extension vision-start/
|
||||
mv manifest.json vision-start/
|
||||
|
||||
- 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: |
|
||||
unzip -o -q ${{ env.ARCHIVE_NAME }}
|
||||
if [ ! -d dist ]; then
|
||||
echo "The build artifact does not contain dist/."
|
||||
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' \
|
||||
"" \
|
||||
'### Editing' \
|
||||
"" \
|
||||
'### Configuration' \
|
||||
"")
|
||||
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"
|
||||
@@ -15,16 +15,33 @@ 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
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Run build
|
||||
run: |
|
||||
@@ -33,20 +50,33 @@ jobs:
|
||||
|
||||
- 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
|
||||
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: |
|
||||
cd vision-start
|
||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
||||
zip -r "../${ARCHIVE_NAME}" *
|
||||
|
||||
- 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
|
||||
@@ -67,7 +97,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
|
||||
@@ -83,7 +113,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
|
||||
@@ -91,6 +121,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:
|
||||
@@ -101,7 +136,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
|
||||
@@ -111,6 +150,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 }}" \
|
||||
@@ -146,3 +196,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
|
||||
|
||||
@@ -8,37 +8,14 @@ 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 } from './components/utils/wallpaperUtils';
|
||||
import { PlusIcon } from './components/icons';
|
||||
|
||||
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 {
|
||||
@@ -81,23 +58,23 @@ 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;
|
||||
const randomIndex = getRandomWallpaperIndex(names.length, safeCurrent);
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: new Date().toISOString(),
|
||||
currentIndex: nextIndex,
|
||||
currentIndex: randomIndex,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error advancing wallpaper state', error);
|
||||
console.error('Error randomizing wallpaper state', error);
|
||||
}
|
||||
setWallpaperVersion(v => v + 1);
|
||||
}, [config.currentWallpapers]);
|
||||
@@ -218,13 +195,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 +209,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 +255,7 @@ const App: React.FC = () => {
|
||||
onClose={() => setIsConfigModalOpen(false)}
|
||||
onSave={handleSaveConfig}
|
||||
onWallpaperChange={handleWallpaperChange}
|
||||
onNextWallpaper={handleNextWallpaper}
|
||||
onRandomWallpaper={handleRandomWallpaper}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getClockSizeClass } from './utils/styleUtils';
|
||||
|
||||
interface ClockProps {
|
||||
config: {
|
||||
@@ -9,10 +10,9 @@ 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(() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ interface ConfigurationModalProps {
|
||||
onSave: (config: Config) => void;
|
||||
currentConfig: Config;
|
||||
onWallpaperChange: (newConfig: Partial<Config>) => void;
|
||||
onNextWallpaper: () => void;
|
||||
onRandomWallpaper: () => void;
|
||||
}
|
||||
|
||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
@@ -21,14 +21,13 @@ 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);
|
||||
|
||||
@@ -64,8 +63,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleAddWallpaper = async (name: string, url: string) => {
|
||||
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
||||
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||
const newWallpaper = await promise;
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
@@ -75,16 +74,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddWallpaperFile = async (file: File) => {
|
||||
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||
}));
|
||||
};
|
||||
const handleAddWallpaper = (name: string, url: string) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||
|
||||
const handleAddWallpaperFile = (file: File) =>
|
||||
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||
|
||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||
try {
|
||||
@@ -140,7 +134,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,7 +170,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
onAddWallpaper={handleAddWallpaper}
|
||||
onAddWallpaperFile={handleAddWallpaperFile}
|
||||
onDeleteWallpaper={handleDeleteWallpaper}
|
||||
onNextWallpaper={onNextWallpaper}
|
||||
onRandomWallpaper={onRandomWallpaper}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'clock' && (
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ModalShellProps {
|
||||
title: string;
|
||||
edit: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
{edit && onDelete && (
|
||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalShell;
|
||||
@@ -12,17 +12,14 @@ interface ServerWidgetProps {
|
||||
};
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
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);
|
||||
|
||||
+29
-28
@@ -1,8 +1,8 @@
|
||||
|
||||
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 } from './utils/wallpaperUtils';
|
||||
|
||||
interface WallpaperProps {
|
||||
wallpaperNames: string[];
|
||||
@@ -13,22 +13,18 @@ interface WallpaperProps {
|
||||
wallpaperVersion: number;
|
||||
}
|
||||
|
||||
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
|
||||
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
|
||||
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseFrequencyToMs = (freq: string): number => {
|
||||
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const match = freq.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
||||
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
||||
};
|
||||
|
||||
const MAX_WALLPAPER_URL_CACHE = 3;
|
||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||
|
||||
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||
wallpaperUrlCache.set(name, resolved);
|
||||
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||
const oldest = wallpaperUrlCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
wallpaperUrlCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||
@@ -43,36 +39,35 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||
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;
|
||||
|
||||
const updateWallpaper = async () => {
|
||||
if (wallpaperNames.length === 0) {
|
||||
setImageUrl(undefined);
|
||||
if (!cancelled) setImageUrl(undefined);
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||
@@ -85,7 +80,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||
: 0;
|
||||
const now = Date.now();
|
||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
||||
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
|
||||
|
||||
let storedIndex =
|
||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||
@@ -93,7 +88,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
|
||||
const shouldRotate = now - lastChange >= freqMs;
|
||||
let resolvedIndex = shouldRotate
|
||||
? (storedIndex + 1) % wallpaperNames.length
|
||||
? getRandomWallpaperIndex(wallpaperNames.length, storedIndex)
|
||||
: storedIndex;
|
||||
|
||||
const tried = new Set<number>();
|
||||
@@ -103,6 +98,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
if (tried.has(resolvedIndex)) break;
|
||||
tried.add(resolvedIndex);
|
||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||
if (cancelled) return;
|
||||
if (url) {
|
||||
resolvedUrl = url;
|
||||
break;
|
||||
@@ -110,6 +106,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const nextLastChange = shouldRotate
|
||||
? new Date().toISOString()
|
||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||
@@ -122,10 +120,13 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
}),
|
||||
);
|
||||
|
||||
resolvedRef.current = true;
|
||||
setImageUrl(resolvedUrl);
|
||||
};
|
||||
updateWallpaper();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||
|
||||
if (!imageUrl) return null;
|
||||
|
||||
@@ -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;
|
||||
@@ -27,6 +28,9 @@ interface IconMetadata {
|
||||
|
||||
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 : '');
|
||||
const [url, setUrl] = useState(website ? website.url : '');
|
||||
@@ -37,6 +41,12 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
iconMetadataCache = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ensureIconMetadata = () => {
|
||||
if (iconMetadataCache) {
|
||||
setIconMetadata(iconMetadataCache);
|
||||
@@ -68,10 +78,10 @@ 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 && typeof ic.colors === 'object') {
|
||||
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||
for (const color of colors as string[]) {
|
||||
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||
filtered.push({ ...ic, name: color });
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
@@ -96,21 +106,14 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ id: website?.id, name, url, icon });
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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" />
|
||||
@@ -153,18 +156,17 @@ 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));
|
||||
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"
|
||||
/>
|
||||
@@ -178,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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+55
-51
@@ -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,49 +12,49 @@ 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(() => {
|
||||
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) return;
|
||||
setIconSource(website.icon);
|
||||
setUsingCachedIcon(false);
|
||||
void removeCachedWebsiteIcon(website.icon);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isEditing) {
|
||||
@@ -84,7 +87,14 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
)}
|
||||
<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" />
|
||||
{iconSource && (
|
||||
<img
|
||||
src={iconSource}
|
||||
alt={`${website.name} icon`}
|
||||
className="object-contain w-full h-full"
|
||||
onError={handleIconError}
|
||||
/>
|
||||
)}
|
||||
</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 +103,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 +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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
export const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
interface RangeSliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
valueSuffix?: string;
|
||||
formatValue?: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const RangeSlider: React.FC<RangeSliderProps> = ({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
valueSuffix,
|
||||
formatValue,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">{label}</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(value, min, max)}
|
||||
/>
|
||||
<span className="min-w-20 text-right text-sm text-slate-200">
|
||||
{formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangeSlider;
|
||||
@@ -2,19 +2,14 @@ import React, { useState } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config, Server } from '../../types';
|
||||
import RangeSlider from './RangeSlider';
|
||||
import { TrashIcon } from '../icons';
|
||||
|
||||
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('');
|
||||
@@ -60,21 +55,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 +91,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>
|
||||
)}
|
||||
|
||||
@@ -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,34 +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 MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||
|
||||
const clampWallpaperFrequencyHours = (hours: number): number =>
|
||||
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
|
||||
|
||||
const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||
const value = Number(match[1]);
|
||||
const hours = match[2] === 'd' ? value * 24 : value;
|
||||
return clampWallpaperFrequencyHours(hours);
|
||||
};
|
||||
|
||||
const formatWallpaperFrequency = (hours: number): string =>
|
||||
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
|
||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
@@ -48,11 +31,10 @@ 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 () => {
|
||||
@@ -62,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);
|
||||
}
|
||||
};
|
||||
@@ -72,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 = '';
|
||||
@@ -92,76 +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>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
<RangeSlider
|
||||
label="Change Frequency"
|
||||
value={wallpaperFrequencyHours}
|
||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||
step="1"
|
||||
value={wallpaperFrequencyHours}
|
||||
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(
|
||||
wallpaperFrequencyHours,
|
||||
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||
)}
|
||||
formatValue={formatWallpaperFrequency}
|
||||
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
|
||||
/>
|
||||
<span className="w-20 text-right text-sm text-slate-200">
|
||||
{formatWallpaperFrequency(wallpaperFrequencyHours)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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">
|
||||
@@ -176,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>
|
||||
))}
|
||||
@@ -220,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"
|
||||
@@ -251,17 +188,15 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
type="file"
|
||||
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"
|
||||
>
|
||||
@@ -274,7 +209,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>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PencilIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlusIcon: React.FC<IconProps> = ({ size = 22, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon: React.FC<IconProps> = ({ size = 16, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronLeftIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { memo } from 'react';
|
||||
import 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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Config, Wallpaper } from '../../types';
|
||||
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];
|
||||
@@ -100,8 +102,18 @@ 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';
|
||||
return { name: finalName, url: parsedUrl.href };
|
||||
},
|
||||
|
||||
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||
@@ -130,6 +142,7 @@ export const ConfigurationService = {
|
||||
},
|
||||
|
||||
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||
if (wallpaper.url || wallpaper.base64 || !checkChromeStorageLocalAvailable()) return;
|
||||
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||
},
|
||||
|
||||
|
||||
@@ -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 result[name] || 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),
|
||||
);
|
||||
}
|
||||
+194
-22
@@ -1,31 +1,203 @@
|
||||
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 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 => {
|
||||
resolvedIconCache.set(iconUrl, dataUrl);
|
||||
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
|
||||
const oldest = resolvedIconCache.keys().next().value;
|
||||
if (oldest !== undefined) resolvedIconCache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
||||
|
||||
const 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' && isDataUrl(value);
|
||||
|
||||
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 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);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching and parsing HTML for icon:', error);
|
||||
}
|
||||
const TRUSTED_TLDS = new Set([
|
||||
'com',
|
||||
'org',
|
||||
'net',
|
||||
'gov',
|
||||
'edu',
|
||||
'io',
|
||||
'co',
|
||||
'dev',
|
||||
'app',
|
||||
'me',
|
||||
'ai',
|
||||
'info',
|
||||
'br',
|
||||
'uk',
|
||||
'de',
|
||||
]);
|
||||
|
||||
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
|
||||
const request = (async () => {
|
||||
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
|
||||
if (cachedIcon) return cachedIcon;
|
||||
|
||||
const fetchSource = getIconFetchSource(iconUrl);
|
||||
if (!fetchSource) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(fetchSource);
|
||||
if (!response.ok || response.type === 'opaque') return null;
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentType = (blob.type || response.headers.get('content-type') || '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!contentType.startsWith('image/') || blob.size === 0 || blob.size > MAX_CACHED_ICON_BYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataUrl = await blobToDataUrl(blob);
|
||||
rememberResolvedIcon(iconUrl, dataUrl);
|
||||
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
||||
return dataUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})().finally(() => {
|
||||
iconCacheRequests.delete(iconUrl);
|
||||
});
|
||||
|
||||
iconCacheRequests.set(iconUrl, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
|
||||
resolvedIconCache.delete(iconUrl);
|
||||
iconCacheLookups.delete(iconUrl);
|
||||
await removeCachedIconFromChromeStorageLocal(iconUrl);
|
||||
}
|
||||
|
||||
export {
|
||||
cacheWebsiteIcon,
|
||||
getCachedWebsiteIcon,
|
||||
getWebsiteIcon,
|
||||
removeCachedWebsiteIcon,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export const SIZE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
];
|
||||
|
||||
export const TILE_SIZE_CLASSES: Record<string, string> = {
|
||||
small: 'w-28 h-28',
|
||||
medium: 'w-32 h-32',
|
||||
large: 'w-36 h-36',
|
||||
};
|
||||
|
||||
export const getTileSizeClass = (size: string | undefined): string =>
|
||||
TILE_SIZE_CLASSES[size ?? ''] ?? 'w-32 h-32';
|
||||
|
||||
export const ICON_PIXEL_SIZES: Record<string, number> = {
|
||||
small: 34,
|
||||
medium: 42,
|
||||
large: 48,
|
||||
};
|
||||
|
||||
export const getIconPixelSize = (size: string | undefined): number =>
|
||||
ICON_PIXEL_SIZES[size ?? ''] ?? 40;
|
||||
|
||||
export const ICON_LOADING_PIXEL_SIZES: Record<string, number> = {
|
||||
small: 24,
|
||||
medium: 32,
|
||||
large: 40,
|
||||
};
|
||||
|
||||
export const getIconLoadingPixelSize = (size: string | undefined): number =>
|
||||
ICON_LOADING_PIXEL_SIZES[size ?? ''] ?? 32;
|
||||
|
||||
export const CLOCK_SIZE_CLASSES: Record<string, string> = {
|
||||
tiny: 'text-3xl',
|
||||
small: 'text-4xl',
|
||||
medium: 'text-5xl',
|
||||
large: 'text-6xl',
|
||||
};
|
||||
|
||||
export const getClockSizeClass = (size: string): string =>
|
||||
CLOCK_SIZE_CLASSES[size] ?? 'text-5xl';
|
||||
|
||||
export const TITLE_SIZE_CLASSES: Record<string, string> = {
|
||||
tiny: 'text-4xl',
|
||||
small: 'text-5xl',
|
||||
medium: 'text-6xl',
|
||||
large: 'text-7xl',
|
||||
};
|
||||
|
||||
export const getTitleSizeClass = (size: string): string =>
|
||||
TITLE_SIZE_CLASSES[size] ?? 'text-6xl';
|
||||
|
||||
export const ALIGNMENT_CLASSES: Record<string, string> = {
|
||||
top: 'justify-start',
|
||||
left: 'justify-start',
|
||||
middle: 'justify-center',
|
||||
bottom: 'justify-end',
|
||||
right: 'justify-end',
|
||||
};
|
||||
|
||||
export const getAlignmentClass = (alignment: string): string =>
|
||||
ALIGNMENT_CLASSES[alignment] ?? 'justify-center';
|
||||
@@ -0,0 +1,9 @@
|
||||
export const getFileNameFromUrl = (url: URL): string => {
|
||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||
if (!pathName) return url.hostname;
|
||||
try {
|
||||
return decodeURIComponent(pathName);
|
||||
} catch {
|
||||
return pathName;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Vision Startpage",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"description": "A light, modern and customizable startpage.",
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "index.html"
|
||||
|
||||
Generated
+48
@@ -19,6 +19,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",
|
||||
@@ -2387,6 +2388,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+42
-20
@@ -1,6 +1,6 @@
|
||||
---
|
||||
project_name: Vision Start
|
||||
date: 2026-07-08
|
||||
date: 2026-08-11
|
||||
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`.
|
||||
|
||||
@@ -54,22 +55,25 @@ The startpage is composed of widgets and a configuration panel:
|
||||
- **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 an hourly cadence selected by slider (`1h`–`48h`).
|
||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||
- 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. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
|
||||
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||
- `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 and hydrated into each icon-picker instance, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
||||
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
|
||||
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. The module-level metadata cache is released when the picker modal unmounts (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||
- `Wallpaper` resolves wallpaper URLs through a module-level `Map` bounded to the last 3 entries — so uploaded base64 wallpapers (up to ~4.5MB strings) are not retained in memory for the page's lifetime; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||
- The website icon service keeps an in-memory resolved data-URL cache bounded to 50 entries (FIFO eviction) to avoid unbounded growth from many tiles.
|
||||
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
|
||||
|
||||
Planned / To-do (tracked in `README.md`):
|
||||
- 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.
|
||||
@@ -96,9 +100,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
|
||||
@@ -110,28 +116,35 @@ vision-start/
|
||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||
│ │ ├── 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
|
||||
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
||||
│ ├── 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
|
||||
│ ├── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
||||
│ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets
|
||||
│ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers
|
||||
│ └── urlUtils.ts # URL → filename extraction (shared by wallpaper storage paths)
|
||||
│
|
||||
├── public/
|
||||
│ ├── 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/
|
||||
│ ├── 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)
|
||||
@@ -151,6 +164,8 @@ 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`
|
||||
@@ -167,11 +182,12 @@ Storage layout (browser-side):
|
||||
|---|---|---|
|
||||
| `config` | `localStorage` | The full `Config` JSON |
|
||||
| `categories` | `localStorage` | `Category[]` JSON |
|
||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
|
||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
|
||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
||||
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data |
|
||||
| `<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.
|
||||
|
||||
---
|
||||
|
||||
@@ -187,7 +203,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -208,6 +225,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.
|
||||
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
||||
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
|
||||
- **`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.
|
||||
|
||||
@@ -220,11 +240,12 @@ 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.
|
||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, both of which share the `ModalShell` component.
|
||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||
- **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 frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only `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.).
|
||||
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||
@@ -246,10 +267,11 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
||||
| 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` |
|
||||
| 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-08. Generated as a general project overview; not a coding-style guide._
|
||||
_Last updated: 2026-08-11. Generated as a general project overview; not a coding-style guide._
|
||||
|
||||
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 |
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"categories": "W3siaWQiOiIxIiwibmFtZSI6IlNlYXJjaCIsIndlYnNpdGVzIjpbeyJpZCI6IjEiLCJuYW1lIjoiR29vZ2xlIiwidXJsIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbSIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1nb29nbGUuY29tJnN6PTEyOCIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQxODAxMDgiLCJuYW1lIjoiWW91VHViZSIsInVybCI6Imh0dHBzOi8vd3d3LnlvdXR1YmUuY29tLyIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy95b3V0dWJlLnN2ZyIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQyMjI2OTQiLCJuYW1lIjoiRHJpdmUiLCJ1cmwiOiJodHRwczovL2RyaXZlLmdvb2dsZS5jb20vZHJpdmUvdS8wL215LWRyaXZlIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dvb2dsZS1kcml2ZS5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDE0ODIwIiwibmFtZSI6IkdtYWlsIiwidXJsIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC91LzAvP3RhYj13bSNpbmJveCIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9nbWFpbC5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDI3NTYwIiwibmFtZSI6Ikxhc3QuZm0iLCJ1cmwiOiJodHRwczovL3d3dy5sYXN0LmZtL2hvbWUiLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49d3d3Lmxhc3QuZm0mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjEifV19LHsiaWQiOiIxNzUzMDQ0NDQ2NzU1IiwibmFtZSI6IkNvZGUiLCJ3ZWJzaXRlcyI6W3siaWQiOiIxNzUzMDQ0NDU5MjcyIiwibmFtZSI6IkdpdGh1YiIsInVybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9mZWVkIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dpdGh1Yi1saWdodC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4ODcwNjgzIiwibmFtZSI6IkFXUyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9hd3MtbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ0NDY3NTUifSx7ImlkIjoiMTc4MzcyODkxNTg1MyIsIm5hbWUiOiJEb2NrZXIgSHViIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2RvY2tlci5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4OTkyMzkwIiwibmFtZSI6IkZpZ21hIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2ZpZ21hLnN2ZyIsImNhdGVnb3J5SWQiOiIxNzUzMDQ0NDQ2NzU1In1dfSx7ImlkIjoiMTc1MzA0NDY2ODAxMiIsIm5hbWUiOiJNZWRpYSIsIndlYnNpdGVzIjpbeyJpZCI6IjE3NTMwNDQ3MDYwMjQiLCJuYW1lIjoiTmV0ZmxpeCIsInVybCI6Imh0dHA6Ly90di5oYXZlbi8iLCJpY29uIjoiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2hvbWFyci1sYWJzL2Rhc2hib2FyZC1pY29ucy9zdmcvbmV0ZmxpeC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzUzMDQ0NzQwOTg2IiwibmFtZSI6IlByaW1lIiwidXJsIjoiaHR0cDovL29tdi5oYXZlbiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9wcmltZS12aWRlby1hbHQtZGFyay5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzgzNzI4Nzg0MjU2IiwibmFtZSI6IkhCTyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9oYm8tbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc1MzA0NDgyODg0MCIsIm5hbWUiOiJUcmFrdCIsInVybCI6Imh0dHBzOi8vYXBwLnRyYWt0LnR2LyIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1hcHAudHJha3QudHYmc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc4MzcyODcyOTQyNCIsIm5hbWUiOiJMZXR0ZXJib3hkIiwidXJsIjoiaHR0cHM6Ly9sZXR0ZXJib3hkLmNvbS8iLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49bGV0dGVyYm94ZC5jb20mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifV19XQ==",
|
||||
"config": "eyJ0aXRsZSI6IiIsImN1cnJlbnRXYWxscGFwZXJzIjpbIkFic3RyYWN0IFJlZCJdLCJ3YWxscGFwZXJGcmVxdWVuY3kiOiIxZCIsIndhbGxwYXBlckJsdXIiOjAsIndhbGxwYXBlckJyaWdodG5lc3MiOjEwOCwid2FsbHBhcGVyT3BhY2l0eSI6MTAwLCJ0aXRsZVNpemUiOiJtZWRpdW0iLCJhbGlnbm1lbnQiOiJtaWRkbGUiLCJob3Jpem9udGFsQWxpZ25tZW50IjoibWlkZGxlIiwidGlsZVNpemUiOiJzbWFsbCIsImNsb2NrIjp7ImVuYWJsZWQiOnRydWUsInNpemUiOiJ0aW55IiwiZm9udCI6Im1vbm9zcGFjZSIsImZvcm1hdCI6Img6bW0gQSJ9LCJzZXJ2ZXJXaWRnZXQiOnsiZW5hYmxlZCI6dHJ1ZSwicGluZ0ZyZXF1ZW5jeSI6MTUsInNlcnZlcnMiOlt7ImlkIjoiMTc4MzcyOTE4MzU1MyIsIm5hbWUiOiJBZEd1YXJkIiwiYWRkcmVzcyI6Imh0dHBzOi8vZ29vZ2xlLmNvbSJ9LHsiaWQiOiIxNzgzNzI5MjI3NDQyIiwibmFtZSI6IlByb3htb3giLCJhZGRyZXNzIjoiaHR0cHM6Ly9nb29nbGUuY29tIn1dfX0=",
|
||||
"userWallpapers": "W3sibmFtZSI6ImRhcmstYWJzdHJhY3QtMjU2MHgxNDQwLWNvbnRlbXBvcmFyeS1kZXNpZ24tc2xlZWstbGluZXMtMjY0MjYuanBnIn1d",
|
||||
"wallpaperState": "eyJsYXN0V2FsbHBhcGVyQ2hhbmdlIjoiMjA0Ni0wNy0xMVQwMDoyMzozMS4zODdaIiwiY3VycmVudEluZGV4IjowfQ=="
|
||||
}
|
||||
Reference in New Issue
Block a user