Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4885fd68c1 | ||
|
|
31de8265d9 | ||
|
|
e08853fe54 | ||
|
|
acd8369285 | ||
|
|
0ecf0eed62 | ||
|
|
7635471ed6 | ||
|
|
8ef643645a | ||
|
|
552379b2a6 | ||
|
|
f9864072cf | ||
|
|
51341c33ca | ||
|
|
8b5c52dd1e | ||
|
|
c3addb6d02 | ||
|
|
5597afc572 | ||
|
|
30372f800c | ||
|
|
9e738cc0d5 | ||
|
|
b60c88e9b1 | ||
|
|
fee538f044 | ||
|
|
48ec764880 | ||
|
|
babd31548c | ||
|
|
95ae04ecd2 | ||
|
|
7a137abb66 | ||
|
|
023b2ffdc5 | ||
|
|
c9dafd76d1 |
@@ -15,27 +15,54 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Vision Start
|
name: Build Vision Start
|
||||||
if: gitea.event_name == 'push'
|
|
||||||
runs-on: ubuntu-amd64
|
runs-on: ubuntu-amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Install JS dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Package dist as zip
|
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
bash scripts/prepare_release.sh
|
||||||
zip -r ../vision-start-build.zip .
|
npm run build
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
mkdir -p vision-start
|
||||||
|
mv dist vision-start/
|
||||||
|
mv extension vision-start/
|
||||||
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
|
- name: Set archive name
|
||||||
|
run: |
|
||||||
|
SAFE_REF="${GITEA_REF_NAME//\//-}"
|
||||||
|
ARCHIVE="vision-start-${SAFE_REF}.zip"
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
|
- name: Create zip archive
|
||||||
|
run: |
|
||||||
|
cd vision-start
|
||||||
|
zip -r "../${ARCHIVE_NAME}" *
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: vision-start-build
|
name: release-zip
|
||||||
path: vision-start-build.zip
|
path: ${{ env.ARCHIVE_NAME }}
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
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,28 +15,68 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
zip-file: vision-start-${{ gitea.ref_name }}.zip
|
zip-file: ${{ steps.set-archive.outputs.archive-name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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
|
- name: Setup required tools
|
||||||
run: sudo apt-get install zip jq curl -y
|
run: sudo apt-get install zip jq curl -y
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
|
||||||
- name: Prepare release
|
|
||||||
run: |
|
run: |
|
||||||
bash scripts/prepare_release.sh
|
bash scripts/prepare_release.sh
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
mkdir -p vision-start
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
|
mv extension vision-start/
|
||||||
mv manifest.json 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
|
- name: Create zip archive
|
||||||
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
|
run: |
|
||||||
|
cd vision-start
|
||||||
|
zip -r "../${ARCHIVE_NAME}" *
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
path: vision-start-${{ gitea.ref_name }}.zip
|
path: ${{ env.ARCHIVE_NAME }}
|
||||||
|
|
||||||
virus-total-check:
|
virus-total-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -57,7 +97,7 @@ jobs:
|
|||||||
id: vt-check
|
id: vt-check
|
||||||
env:
|
env:
|
||||||
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
||||||
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
VIRUS_TOTAL_FILE: ${{ needs.build.outputs.zip-file }}
|
||||||
run: |
|
run: |
|
||||||
# Run the VirusTotal check script and capture output in real-time
|
# Run the VirusTotal check script and capture output in real-time
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
@@ -73,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build, virus-total-check]
|
needs: [build, virus-total-check, capture_screenshots]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -81,6 +121,11 @@ jobs:
|
|||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
|
- name: Download screenshot artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-screenshots
|
||||||
|
path: release-screenshots
|
||||||
- name: Release zip
|
- name: Release zip
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
@@ -91,7 +136,11 @@ jobs:
|
|||||||
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
tag_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:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
@@ -101,6 +150,17 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v2
|
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
|
- name: Log in to Container Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
@@ -136,3 +196,38 @@ jobs:
|
|||||||
cd ${{ secrets.PROD_DIR }}
|
cd ${{ secrets.PROD_DIR }}
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d --force-recreate
|
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 CategoryGroup from './components/layout/CategoryGroup';
|
||||||
import Wallpaper from './components/Wallpaper';
|
import Wallpaper from './components/Wallpaper';
|
||||||
import { ConfigurationService } from './components/services/ConfigurationService';
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
|
import { getAlignmentClass } from './components/utils/styleUtils';
|
||||||
|
import { getRandomWallpaperIndex } from './components/utils/wallpaperUtils';
|
||||||
|
import { PlusIcon } from './components/icons';
|
||||||
|
|
||||||
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||||
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||||
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||||
|
|
||||||
const getAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'top':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'bottom':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHorizontalAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'left':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'right':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<Category[]>(() => {
|
const [categories, setCategories] = useState<Category[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -81,23 +58,23 @@ const App: React.FC = () => {
|
|||||||
setConfig(prev => ({ ...prev, ...newConfig }));
|
setConfig(prev => ({ ...prev, ...newConfig }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNextWallpaper = useCallback(() => {
|
const handleRandomWallpaper = useCallback(() => {
|
||||||
const names = config.currentWallpapers;
|
const names = config.currentWallpapers;
|
||||||
if (names.length === 0) return;
|
if (names.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||||
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
|
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
|
||||||
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
|
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
|
||||||
const nextIndex = (safeCurrent + 1) % names.length;
|
const randomIndex = getRandomWallpaperIndex(names.length, safeCurrent);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'wallpaperState',
|
'wallpaperState',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
lastWallpaperChange: new Date().toISOString(),
|
lastWallpaperChange: new Date().toISOString(),
|
||||||
currentIndex: nextIndex,
|
currentIndex: randomIndex,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error advancing wallpaper state', error);
|
console.error('Error randomizing wallpaper state', error);
|
||||||
}
|
}
|
||||||
setWallpaperVersion(v => v + 1);
|
setWallpaperVersion(v => v + 1);
|
||||||
}, [config.currentWallpapers]);
|
}, [config.currentWallpapers]);
|
||||||
@@ -218,13 +195,12 @@ const App: React.FC = () => {
|
|||||||
setAddingWebsite={setAddingWebsite}
|
setAddingWebsite={setAddingWebsite}
|
||||||
setEditingWebsite={setEditingWebsite}
|
setEditingWebsite={setEditingWebsite}
|
||||||
handleMoveWebsite={handleMoveWebsite}
|
handleMoveWebsite={handleMoveWebsite}
|
||||||
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
|
||||||
horizontalAlignment={config.horizontalAlignment}
|
horizontalAlignment={config.horizontalAlignment}
|
||||||
tileSize={config.tileSize}
|
tileSize={config.tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
|
<div className="flex justify-center transition-all duration-200 ease-ios transform scale-100 opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
@@ -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"
|
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
||||||
aria-label="Add category"
|
aria-label="Add category"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PlusIcon size={22} />
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
||||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
|
||||||
</svg>
|
|
||||||
Add category
|
Add category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +255,7 @@ const App: React.FC = () => {
|
|||||||
onClose={() => setIsConfigModalOpen(false)}
|
onClose={() => setIsConfigModalOpen(false)}
|
||||||
onSave={handleSaveConfig}
|
onSave={handleSaveConfig}
|
||||||
onWallpaperChange={handleWallpaperChange}
|
onWallpaperChange={handleWallpaperChange}
|
||||||
onNextWallpaper={handleNextWallpaper}
|
onRandomWallpaper={handleRandomWallpaper}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Vision Startpage Project
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Privacy Policy for Vision Start
|
||||||
|
|
||||||
|
This Privacy Policy describes how the Vision Start Chrome extension handles user information.
|
||||||
|
|
||||||
|
## Information Stored by the Extension
|
||||||
|
|
||||||
|
Vision Start stores extension settings and user-provided configuration locally in the browser using Chrome's local extension storage.
|
||||||
|
|
||||||
|
This information may include:
|
||||||
|
* Start-page preferences and appearance settings;
|
||||||
|
* Bookmarks, shortcuts, or server and services addresses added by the user;
|
||||||
|
* Configuration for features enabled by the user.
|
||||||
|
|
||||||
|
This information is used only to provide the extension's functionality. It is not transmitted to, collected by, or stored on servers operated by the developer.
|
||||||
|
|
||||||
|
The developer does not have access to information stored locally by the extension.
|
||||||
|
|
||||||
|
## Server Status Requests
|
||||||
|
|
||||||
|
Vision Start can send network requests to server addresses configured by the user in order to check whether those servers are available.
|
||||||
|
|
||||||
|
These requests are sent directly from the user's browser to the configured server. The developer does not receive, proxy, log, or store these requests.
|
||||||
|
|
||||||
|
The destination server may receive standard network information associated with the request, such as the user's IP address, browser request headers, and the requested server address. The handling of that information is controlled by the operator of the destination server.
|
||||||
|
|
||||||
|
## External Favicon Service
|
||||||
|
|
||||||
|
Vision Start uses the Google Favicon Service (https://www.google.com/s2/favicons) to retrieve icons for websites configured by the user.
|
||||||
|
|
||||||
|
When an icon is requested, the relevant website domain is sent directly from the user's browser to Google. Google may also receive standard information associated with the network request, such as the user's IP address, browser request headers, and the requested domain.
|
||||||
|
|
||||||
|
The developer does not receive, proxy, log, or store these requests. Google's handling of information is governed by the Google Privacy Policy.
|
||||||
|
|
||||||
|
## Data Sharing and Sale
|
||||||
|
|
||||||
|
The developer does not sell, rent, trade, or use user information for advertising, analytics, profiling, or marketing.
|
||||||
|
|
||||||
|
No locally stored extension data is shared with the developer or with third parties, except for the direct network requests described above that are necessary to provide user-requested features.
|
||||||
|
|
||||||
|
## Data Retention and Deletion
|
||||||
|
|
||||||
|
Information stored by Vision Start remains in the browser until the user:
|
||||||
|
|
||||||
|
* Deletes the relevant information through the extension;
|
||||||
|
* Clears the extension's browser storage; or
|
||||||
|
* Uninstalls the extension.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Vision Start is designed to keep user configuration on the user's device. Users should use HTTPS addresses whenever possible when configuring remote servers or external resources.
|
||||||
|
|
||||||
|
## Changes to This Policy
|
||||||
|
|
||||||
|
This Privacy Policy may be updated if the extension's functionality or data-handling practices change.
|
||||||
|
|
||||||
|
Any updated version will be published at this location with a revised effective date. Material changes to how user information is handled will also be disclosed through the extension or its Chrome Web Store listing where appropriate.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Questions about this Privacy Policy may be submitted through the developer's GitHub profile: https://github.com/ivanch
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
|
<div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
|
||||||
Vision Start
|
<img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
||||||
A light liquid-glass, modern and customizable startpage built with React.
|
A light, modern and customizable startpage built with React.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
||||||
@@ -63,7 +63,7 @@ npm run dev
|
|||||||
* [x] Multiple Wallpapers
|
* [x] Multiple Wallpapers
|
||||||
* [x] Remake icons
|
* [x] Remake icons
|
||||||
* [/] Increase offline compatibility (might not be possible)
|
* [/] 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
|
- [ ] 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
|
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
|
||||||
* Dynamic Weather Widget
|
* Dynamic Weather Widget
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Category } from '../types';
|
import { Category } from '../types';
|
||||||
|
import ModalShell from './ModalShell';
|
||||||
|
|
||||||
interface CategoryEditModalProps {
|
interface CategoryEditModalProps {
|
||||||
category?: Category;
|
category?: Category;
|
||||||
@@ -12,44 +13,22 @@ interface CategoryEditModalProps {
|
|||||||
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(category ? category.name : '');
|
const [name, setName] = useState(category ? category.name : '');
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
<ModalShell
|
||||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
title={edit ? 'Edit Category' : 'Add Category'}
|
||||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
edit={edit}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={onClose}
|
||||||
<input
|
onSave={() => onSave(name)}
|
||||||
type="text"
|
onDelete={edit ? onDelete : undefined}
|
||||||
placeholder="Category Name"
|
>
|
||||||
value={name}
|
<input
|
||||||
onChange={(e) => setName(e.target.value)}
|
type="text"
|
||||||
className="liquid-input p-3"
|
placeholder="Category Name"
|
||||||
/>
|
value={name}
|
||||||
</div>
|
onChange={(e) => setName(e.target.value)}
|
||||||
<div className="flex justify-between items-center mt-8">
|
className="liquid-input p-3"
|
||||||
<div>
|
/>
|
||||||
{edit && (
|
</ModalShell>
|
||||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { getClockSizeClass } from './utils/styleUtils';
|
||||||
|
|
||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
config: {
|
config: {
|
||||||
@@ -9,10 +10,9 @@ interface ClockProps {
|
|||||||
format: string;
|
format: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getClockSizeClass: (size: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||||
const [time, setTime] = useState(new Date());
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface ConfigurationModalProps {
|
|||||||
onSave: (config: Config) => void;
|
onSave: (config: Config) => void;
|
||||||
currentConfig: Config;
|
currentConfig: Config;
|
||||||
onWallpaperChange: (newConfig: Partial<Config>) => void;
|
onWallpaperChange: (newConfig: Partial<Config>) => void;
|
||||||
onNextWallpaper: () => void;
|
onRandomWallpaper: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||||
@@ -21,14 +21,13 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
onSave,
|
onSave,
|
||||||
currentConfig,
|
currentConfig,
|
||||||
onWallpaperChange,
|
onWallpaperChange,
|
||||||
onNextWallpaper,
|
onRandomWallpaper,
|
||||||
}) => {
|
}) => {
|
||||||
const [config, setConfig] = useState<Config>(currentConfig);
|
const [config, setConfig] = useState<Config>(currentConfig);
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const importInputRef = useRef<HTMLInputElement>(null);
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isSaving = useRef(false);
|
const isSaving = useRef(false);
|
||||||
|
|
||||||
@@ -64,8 +63,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
setConfig((prev) => ({ ...prev, ...updates }));
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaper = async (name: string, url: string) => {
|
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||||
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
const newWallpaper = await promise;
|
||||||
const updated = [...userWallpapers, newWallpaper];
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
setUserWallpapers(updated);
|
setUserWallpapers(updated);
|
||||||
ConfigurationService.saveUserWallpapers(updated);
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
@@ -75,16 +74,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaperFile = async (file: File) => {
|
const handleAddWallpaper = (name: string, url: string) =>
|
||||||
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||||
const updated = [...userWallpapers, newWallpaper];
|
|
||||||
setUserWallpapers(updated);
|
const handleAddWallpaperFile = (file: File) =>
|
||||||
ConfigurationService.saveUserWallpapers(updated);
|
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||||
try {
|
try {
|
||||||
@@ -140,7 +134,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
|
||||||
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
||||||
isVisible ? 'translate-x-0' : 'translate-x-full'
|
isVisible ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
@@ -177,7 +170,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
onAddWallpaper={handleAddWallpaper}
|
onAddWallpaper={handleAddWallpaper}
|
||||||
onAddWallpaperFile={handleAddWallpaperFile}
|
onAddWallpaperFile={handleAddWallpaperFile}
|
||||||
onDeleteWallpaper={handleDeleteWallpaper}
|
onDeleteWallpaper={handleDeleteWallpaper}
|
||||||
onNextWallpaper={onNextWallpaper}
|
onRandomWallpaper={onRandomWallpaper}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'clock' && (
|
{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) => {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
switch (status) {
|
online: 'bg-green-400 text-green-400',
|
||||||
case 'online':
|
offline: 'bg-red-400 text-red-400',
|
||||||
return 'bg-green-400 text-green-400';
|
|
||||||
case 'offline':
|
|
||||||
return 'bg-red-400 text-red-400';
|
|
||||||
default:
|
|
||||||
return 'bg-slate-400 text-slate-400';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string =>
|
||||||
|
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
|
||||||
|
|
||||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||||
const serversRef = useRef(config.serverWidget.servers);
|
const serversRef = useRef(config.serverWidget.servers);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { Wallpaper as WallpaperType } from '../types';
|
import { Wallpaper as WallpaperType } from '../types';
|
||||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||||
|
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
|
||||||
|
|
||||||
interface WallpaperProps {
|
interface WallpaperProps {
|
||||||
wallpaperNames: string[];
|
wallpaperNames: string[];
|
||||||
@@ -13,19 +13,18 @@ interface WallpaperProps {
|
|||||||
wallpaperVersion: number;
|
wallpaperVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseFrequencyToMs = (freq: string): number => {
|
const MAX_WALLPAPER_URL_CACHE = 3;
|
||||||
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
|
||||||
const match = freq.match(/(\d+)(h|d)/);
|
|
||||||
if (!match) return 24 * 60 * 60 * 1000;
|
|
||||||
const value = parseInt(match[1], 10);
|
|
||||||
const unit = match[2];
|
|
||||||
if (unit === 'h') return value * 60 * 60 * 1000;
|
|
||||||
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
|
||||||
return 24 * 60 * 60 * 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||||
|
wallpaperUrlCache.set(name, resolved);
|
||||||
|
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||||
|
const oldest = wallpaperUrlCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
wallpaperUrlCache.delete(oldest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
if (!name) return undefined;
|
if (!name) return undefined;
|
||||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||||
@@ -40,16 +39,14 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
|||||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
if (foundInUser) {
|
if (foundInUser) {
|
||||||
try {
|
resolved = foundInUser.url || foundInUser.base64;
|
||||||
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
if (!resolved) {
|
||||||
if (wallpaperData && wallpaperData.startsWith('http')) {
|
try {
|
||||||
resolved = wallpaperData;
|
resolved = (await getWallpaperFromChromeStorageLocal(name)) || undefined;
|
||||||
} else {
|
} catch (error) {
|
||||||
resolved = wallpaperData || undefined;
|
console.error('Error getting wallpaper from chrome storage', error);
|
||||||
|
resolved = undefined;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting wallpaper from chrome storage', error);
|
|
||||||
resolved = undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -58,18 +55,19 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wallpaperUrlCache.set(name, resolved);
|
rememberWallpaperUrl(name, resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
const resolvedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const updateWallpaper = async () => {
|
const updateWallpaper = async () => {
|
||||||
if (wallpaperNames.length === 0) {
|
if (wallpaperNames.length === 0) {
|
||||||
setImageUrl(undefined);
|
if (!cancelled) setImageUrl(undefined);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'wallpaperState',
|
'wallpaperState',
|
||||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||||
@@ -82,7 +80,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||||
: 0;
|
: 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
|
||||||
|
|
||||||
let storedIndex =
|
let storedIndex =
|
||||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||||
@@ -90,7 +88,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
|
|
||||||
const shouldRotate = now - lastChange >= freqMs;
|
const shouldRotate = now - lastChange >= freqMs;
|
||||||
let resolvedIndex = shouldRotate
|
let resolvedIndex = shouldRotate
|
||||||
? (storedIndex + 1) % wallpaperNames.length
|
? getRandomWallpaperIndex(wallpaperNames.length, storedIndex)
|
||||||
: storedIndex;
|
: storedIndex;
|
||||||
|
|
||||||
const tried = new Set<number>();
|
const tried = new Set<number>();
|
||||||
@@ -100,6 +98,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
if (tried.has(resolvedIndex)) break;
|
if (tried.has(resolvedIndex)) break;
|
||||||
tried.add(resolvedIndex);
|
tried.add(resolvedIndex);
|
||||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||||
|
if (cancelled) return;
|
||||||
if (url) {
|
if (url) {
|
||||||
resolvedUrl = url;
|
resolvedUrl = url;
|
||||||
break;
|
break;
|
||||||
@@ -107,6 +106,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
const nextLastChange = shouldRotate
|
const nextLastChange = shouldRotate
|
||||||
? new Date().toISOString()
|
? new Date().toISOString()
|
||||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||||
@@ -119,10 +120,13 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
resolvedRef.current = true;
|
|
||||||
setImageUrl(resolvedUrl);
|
setImageUrl(resolvedUrl);
|
||||||
};
|
};
|
||||||
updateWallpaper();
|
updateWallpaper();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||||
|
|
||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import { getWebsiteIcon } from './utils/iconService';
|
import { getWebsiteIcon } from './utils/iconService';
|
||||||
|
import ModalShell from './ModalShell';
|
||||||
|
|
||||||
interface WebsiteEditModalProps {
|
interface WebsiteEditModalProps {
|
||||||
website?: Website;
|
website?: Website;
|
||||||
@@ -27,18 +28,31 @@ interface IconMetadata {
|
|||||||
|
|
||||||
let iconMetadataCache: IconMetadata[] | null = null;
|
let iconMetadataCache: IconMetadata[] | null = null;
|
||||||
|
|
||||||
|
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||||
|
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||||
|
|
||||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(website ? website.name : '');
|
const [name, setName] = useState(website ? website.name : '');
|
||||||
const [url, setUrl] = useState(website ? website.url : '');
|
const [url, setUrl] = useState(website ? website.url : '');
|
||||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||||
const [iconQuery, setIconQuery] = useState('');
|
const [iconQuery, setIconQuery] = useState('');
|
||||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>(() => iconMetadataCache ?? []);
|
||||||
const [iconsFetched, setIconsFetched] = useState(false);
|
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||||
const debounceRef = useRef<number | null>(null);
|
const debounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
iconMetadataCache = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const ensureIconMetadata = () => {
|
const ensureIconMetadata = () => {
|
||||||
if (iconMetadataCache || iconsFetched) return;
|
if (iconMetadataCache) {
|
||||||
|
setIconMetadata(iconMetadataCache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (iconsFetched) return;
|
||||||
setIconsFetched(true);
|
setIconsFetched(true);
|
||||||
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -64,10 +78,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
filtered.push(ic);
|
filtered.push(ic);
|
||||||
if (filtered.length >= 50) break;
|
if (filtered.length >= 50) break;
|
||||||
}
|
}
|
||||||
if (ic.colors) {
|
if (ic.colors && typeof ic.colors === 'object') {
|
||||||
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
|
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||||
for (const color of colors) {
|
for (const color of colors as string[]) {
|
||||||
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
|
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||||
filtered.push({ ...ic, name: color });
|
filtered.push({ ...ic, name: color });
|
||||||
if (filtered.length >= 50) break;
|
if (filtered.length >= 50) break;
|
||||||
}
|
}
|
||||||
@@ -92,108 +106,81 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave({ id: website?.id, name, url, icon });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
<ModalShell
|
||||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
title={edit ? 'Edit Website' : 'Add Website'}
|
||||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
edit={edit}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={onClose}
|
||||||
<div className="flex justify-center mb-4">
|
onSave={() => onSave({ id: website?.id, name, url, icon })}
|
||||||
{icon ? (
|
onDelete={edit ? onDelete : undefined}
|
||||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
>
|
||||||
) : (
|
<div className="flex justify-center mb-4">
|
||||||
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
{icon ? (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
) : (
|
||||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||||
</svg>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
</div>
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
)}
|
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
)}
|
||||||
type="text"
|
|
||||||
placeholder="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="liquid-input p-3"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="URL"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
className="liquid-input p-3"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Icon URL or name"
|
|
||||||
value={icon}
|
|
||||||
onChange={(e) => {
|
|
||||||
setIcon(e.target.value);
|
|
||||||
setIconQuery(e.target.value);
|
|
||||||
}}
|
|
||||||
onFocus={ensureIconMetadata}
|
|
||||||
className="liquid-input p-3"
|
|
||||||
/>
|
|
||||||
{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 => (
|
|
||||||
<div
|
|
||||||
key={iconData.name}
|
|
||||||
onClick={() => {
|
|
||||||
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
|
||||||
setIcon(iconUrl);
|
|
||||||
setFilteredIcons([]);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer flex items-center p-2 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}`}
|
|
||||||
alt={iconData.name}
|
|
||||||
className="h-6 w-6 mr-2"
|
|
||||||
/>
|
|
||||||
<span>{iconData.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
|
||||||
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>
|
||||||
</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="liquid-input p-3"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="URL"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="liquid-input p-3"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Icon URL or name"
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIcon(e.target.value);
|
||||||
|
setIconQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={ensureIconMetadata}
|
||||||
|
className="liquid-input p-3"
|
||||||
|
/>
|
||||||
|
{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, index) => (
|
||||||
|
<div
|
||||||
|
key={`${iconData.name}-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIcon(getIconPickUrl(iconData));
|
||||||
|
setFilteredIcons([]);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getIconPickUrl(iconData)}
|
||||||
|
alt={iconData.name}
|
||||||
|
className="h-6 w-6 mr-2"
|
||||||
|
/>
|
||||||
|
<span>{iconData.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
||||||
|
Fetch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React, { memo, useState } from 'react';
|
import React, { memo, useEffect, useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
|
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
||||||
|
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
website: Website;
|
website: Website;
|
||||||
@@ -9,49 +12,49 @@ interface WebsiteTileProps {
|
|||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTileSizeClass = (size: string | undefined) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const getIconPixelSize = (size: string | undefined): number => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 34;
|
|
||||||
case 'medium':
|
|
||||||
return 42;
|
|
||||||
case 'large':
|
|
||||||
return 48;
|
|
||||||
default:
|
|
||||||
return 40;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconLoadingPixelSize = (size: string | undefined): number => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 24;
|
|
||||||
case 'medium':
|
|
||||||
return 32;
|
|
||||||
case 'large':
|
|
||||||
return 40;
|
|
||||||
default:
|
|
||||||
return 32;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
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) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (isEditing) {
|
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={`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}`}>
|
<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>
|
</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' : ''}`}>
|
<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}
|
{website.name}
|
||||||
@@ -93,15 +103,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
</a>
|
</a>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="liquid-surface liquid-edit-toolbar">
|
<div className="liquid-surface liquid-edit-toolbar">
|
||||||
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
|
||||||
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
|
||||||
</svg></button>
|
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></button>
|
||||||
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
|
||||||
</svg></button>
|
|
||||||
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
|
||||||
</svg></button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface ClockTabProps {
|
interface ClockTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
name="clock.size"
|
name="clock.size"
|
||||||
value={config.clock.size}
|
value={config.clock.size}
|
||||||
onChange={(e) => updateClock({ size: e.target.value as string })}
|
onChange={(e) => updateClock({ size: e.target.value as string })}
|
||||||
options={[
|
options={SIZE_OPTIONS}
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
name="titleSize"
|
name="titleSize"
|
||||||
value={config.titleSize}
|
value={config.titleSize}
|
||||||
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
||||||
options={[
|
options={SIZE_OPTIONS}
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||||
|
|
||||||
|
export const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||||
|
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||||
|
return { '--range-progress': `${progress}%` };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RangeSliderProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
valueSuffix?: string;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RangeSlider: React.FC<RangeSliderProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
valueSuffix,
|
||||||
|
formatValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">{label}</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(value, min, max)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-20 text-right text-sm text-slate-200">
|
||||||
|
{formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RangeSlider;
|
||||||
@@ -2,19 +2,14 @@ import React, { useState } from 'react';
|
|||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import { Config, Server } from '../../types';
|
import { Config, Server } from '../../types';
|
||||||
|
import RangeSlider from './RangeSlider';
|
||||||
|
import { TrashIcon } from '../icons';
|
||||||
|
|
||||||
interface ServerWidgetTabProps {
|
interface ServerWidgetTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
onChange: (updates: Partial<Config>) => void;
|
onChange: (updates: Partial<Config>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
|
||||||
|
|
||||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
|
||||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
||||||
return { '--range-progress': `${progress}%` };
|
|
||||||
};
|
|
||||||
|
|
||||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
const [newServerName, setNewServerName] = useState('');
|
const [newServerName, setNewServerName] = useState('');
|
||||||
const [newServerAddress, setNewServerAddress] = useState('');
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
@@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
{config.serverWidget.enabled && (
|
{config.serverWidget.enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
label="Ping Frequency"
|
||||||
<div className="flex items-center gap-4">
|
value={config.serverWidget.pingFrequency}
|
||||||
<input
|
min={5}
|
||||||
type="range"
|
max={60}
|
||||||
min="5"
|
valueSuffix="s"
|
||||||
max="60"
|
onChange={(value) => updateServerWidget({ pingFrequency: value })}
|
||||||
value={config.serverWidget.pingFrequency}
|
/>
|
||||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
|
||||||
/>
|
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
@@ -103,20 +91,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
aria-label={`Remove ${server.name}`}
|
aria-label={`Remove ${server.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<TrashIcon size={16} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
fill="currentColor"
|
|
||||||
className="bi bi-trash"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
>
|
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import { Config, Wallpaper } from '../../types';
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
import RangeSlider from './RangeSlider';
|
||||||
|
import { TrashIcon } from '../icons';
|
||||||
|
import {
|
||||||
|
getWallpaperFrequencyHours,
|
||||||
|
formatWallpaperFrequency,
|
||||||
|
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
} from '../utils/wallpaperUtils';
|
||||||
|
|
||||||
interface ThemeTabProps {
|
interface ThemeTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -11,16 +19,9 @@ interface ThemeTabProps {
|
|||||||
onAddWallpaper: (name: string, url: string) => Promise<void>;
|
onAddWallpaper: (name: string, url: string) => Promise<void>;
|
||||||
onAddWallpaperFile: (file: File) => Promise<void>;
|
onAddWallpaperFile: (file: File) => Promise<void>;
|
||||||
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
|
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
|
||||||
onNextWallpaper: () => void;
|
onRandomWallpaper: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
|
||||||
|
|
||||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
|
||||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
||||||
return { '--range-progress': `${progress}%` };
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -30,11 +31,11 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
onAddWallpaper,
|
onAddWallpaper,
|
||||||
onAddWallpaperFile,
|
onAddWallpaperFile,
|
||||||
onDeleteWallpaper,
|
onDeleteWallpaper,
|
||||||
onNextWallpaper,
|
onRandomWallpaper,
|
||||||
}) => {
|
}) => {
|
||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||||
|
|
||||||
const handleAddWallpaper = async () => {
|
const handleAddWallpaper = async () => {
|
||||||
if (newWallpaperUrl.trim() === '') return;
|
if (newWallpaperUrl.trim() === '') return;
|
||||||
@@ -43,7 +44,11 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
setNewWallpaperName('');
|
setNewWallpaperName('');
|
||||||
setNewWallpaperUrl('');
|
setNewWallpaperUrl('');
|
||||||
} catch (error) {
|
} 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);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -53,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await onAddWallpaperFile(file);
|
await onAddWallpaperFile(file);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -73,170 +78,125 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
label="Change Frequency"
|
||||||
<Dropdown
|
value={wallpaperFrequencyHours}
|
||||||
name="wallpaperFrequency"
|
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||||
value={config.wallpaperFrequency}
|
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||||
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
|
formatValue={formatWallpaperFrequency}
|
||||||
options={[
|
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
|
||||||
{ value: '1h', label: '1 hour' },
|
/>
|
||||||
{ value: '3h', label: '3 hours' },
|
|
||||||
{ value: '6h', label: '6 hours' },
|
|
||||||
{ value: '12h', label: '12 hours' },
|
|
||||||
{ value: '1d', label: '1 day' },
|
|
||||||
{ value: '2d', label: '2 days' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
label="Wallpaper Blur"
|
||||||
<div className="flex items-center gap-4">
|
value={config.wallpaperBlur}
|
||||||
<input
|
min={0}
|
||||||
type="range"
|
max={50}
|
||||||
min="0"
|
valueSuffix="px"
|
||||||
max="50"
|
onChange={(value) => onChange({ wallpaperBlur: value })}
|
||||||
value={config.wallpaperBlur}
|
/>
|
||||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
<RangeSlider
|
||||||
className="liquid-range"
|
label="Wallpaper Brightness"
|
||||||
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
value={config.wallpaperBrightness}
|
||||||
/>
|
min={0}
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
max={200}
|
||||||
|
valueSuffix="%"
|
||||||
|
onChange={(value) => onChange({ wallpaperBrightness: value })}
|
||||||
|
/>
|
||||||
|
<RangeSlider
|
||||||
|
label="Wallpaper Opacity"
|
||||||
|
value={config.wallpaperOpacity}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
valueSuffix="%"
|
||||||
|
onChange={(value) => onChange({ wallpaperOpacity: value })}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{userWallpapers.map((wallpaper) => (
|
||||||
|
<div
|
||||||
|
key={wallpaper.name}
|
||||||
|
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
|
||||||
|
>
|
||||||
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||||
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
|
aria-label={`Delete ${wallpaper.name}`}
|
||||||
|
>
|
||||||
|
<TrashIcon size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="text"
|
||||||
min="0"
|
placeholder="Wallpaper Name (optional for URLs)"
|
||||||
max="200"
|
value={newWallpaperName}
|
||||||
value={config.wallpaperBrightness}
|
onChange={(e) => setNewWallpaperName(e.target.value)}
|
||||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
className="liquid-input p-2.5"
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
|
||||||
/>
|
/>
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
</div>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
placeholder="Image URL"
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
value={newWallpaperUrl}
|
||||||
<div className="flex items-center gap-4">
|
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
||||||
<input
|
className="liquid-input p-2.5"
|
||||||
type="range"
|
/>
|
||||||
min="1"
|
<button
|
||||||
max="100"
|
onClick={handleAddWallpaper}
|
||||||
value={config.wallpaperOpacity}
|
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
>
|
||||||
className="liquid-range"
|
Add
|
||||||
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
</button>
|
||||||
/>
|
</div>
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
{chromeStorageAvailable && (
|
||||||
</div>
|
<div className="flex items-center justify-center w-full">
|
||||||
</div>
|
<label
|
||||||
{chromeStorageAvailable && (
|
htmlFor="file-upload"
|
||||||
<>
|
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
|
||||||
<div>
|
>
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
<div className="flex flex-col gap-2">
|
<svg
|
||||||
{userWallpapers.map((wallpaper) => (
|
className="w-8 h-8 mb-4 text-gray-400"
|
||||||
<div
|
aria-hidden="true"
|
||||||
key={wallpaper.name}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
|
fill="none"
|
||||||
>
|
viewBox="0 0 20 16"
|
||||||
<span className="truncate">{wallpaper.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => onDeleteWallpaper(wallpaper)}
|
|
||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
|
||||||
aria-label={`Delete ${wallpaper.name}`}
|
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
stroke="currentColor"
|
||||||
width="16"
|
strokeLinecap="round"
|
||||||
height="16"
|
strokeLinejoin="round"
|
||||||
fill="currentColor"
|
strokeWidth="2"
|
||||||
className="bi bi-trash"
|
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
|
||||||
viewBox="0 0 16 16"
|
/>
|
||||||
>
|
</svg>
|
||||||
<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" />
|
<p className="mb-2 text-sm text-gray-400">
|
||||||
<path
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
fillRule="evenodd"
|
</p>
|
||||||
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"
|
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Wallpaper Name (optional for URLs)"
|
|
||||||
value={newWallpaperName}
|
|
||||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
|
||||||
className="liquid-input p-2.5"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
id="file-upload"
|
||||||
placeholder="Image URL"
|
type="file"
|
||||||
value={newWallpaperUrl}
|
className="hidden"
|
||||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
onChange={handleFileUpload}
|
||||||
className="liquid-input p-2.5"
|
|
||||||
/>
|
/>
|
||||||
<button
|
</label>
|
||||||
onClick={handleAddWallpaper}
|
|
||||||
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center w-full">
|
|
||||||
<label
|
|
||||||
htmlFor="file-upload"
|
|
||||||
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 mb-4 text-gray-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 20 16"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth="2"
|
|
||||||
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="mb-2 text-sm text-gray-400">
|
|
||||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
ref={fileInputRef}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={onNextWallpaper}
|
onClick={onRandomWallpaper}
|
||||||
disabled={config.currentWallpapers.length === 0}
|
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"
|
className="liquid-surface liquid-control liquid-focus disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-2xl"
|
||||||
>
|
>
|
||||||
@@ -249,7 +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" />
|
<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>
|
</svg>
|
||||||
Next Wallpaper
|
Random Wallpaper
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PencilIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlusIcon: React.FC<IconProps> = ({ size = 22, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon: React.FC<IconProps> = ({ size = 16, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
|
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronLeftIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import WebsiteTile from '../WebsiteTile';
|
import WebsiteTile from '../WebsiteTile';
|
||||||
import { Category, Website } from '../../types';
|
import { Category, Website } from '../../types';
|
||||||
|
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
|
||||||
|
import { PencilIcon, PlusIcon } from '../icons';
|
||||||
|
|
||||||
interface CategoryGroupProps {
|
interface CategoryGroupProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
|
|||||||
setAddingWebsite: (category: Category) => void;
|
setAddingWebsite: (category: Category) => void;
|
||||||
setEditingWebsite: (website: Website) => void;
|
setEditingWebsite: (website: Website) => void;
|
||||||
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
||||||
getHorizontalAlignmentClass: (alignment: string) => string;
|
|
||||||
horizontalAlignment: string;
|
horizontalAlignment: string;
|
||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAddTileSizeClass = (size: string | undefined) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
category,
|
category,
|
||||||
isEditing,
|
isEditing,
|
||||||
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
setAddingWebsite,
|
setAddingWebsite,
|
||||||
setEditingWebsite,
|
setEditingWebsite,
|
||||||
handleMoveWebsite,
|
handleMoveWebsite,
|
||||||
getHorizontalAlignmentClass,
|
|
||||||
horizontalAlignment,
|
horizontalAlignment,
|
||||||
tileSize,
|
tileSize,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="w-full">
|
<div key={category.id} className="w-full">
|
||||||
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
<div className={`flex ${getAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||||
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
@@ -53,13 +40,11 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
aria-label={`Edit ${category.name} category`}
|
aria-label={`Edit ${category.name} category`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PencilIcon size={14} />
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
<div className={`flex flex-wrap ${getAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||||
{category.websites.map((website) => (
|
{category.websites.map((website) => (
|
||||||
<WebsiteTile
|
<WebsiteTile
|
||||||
key={website.id}
|
key={website.id}
|
||||||
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddingWebsite(category)}
|
onClick={() => setAddingWebsite(category)}
|
||||||
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getAddTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
aria-label={`Add website to ${category.name}`}
|
aria-label={`Add website to ${category.name}`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PlusIcon size={28} />
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
||||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-bold">Add</span>
|
<span className="text-sm font-bold">Add</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
import { PencilIcon } from '../icons';
|
||||||
|
|
||||||
interface EditButtonProps {
|
interface EditButtonProps {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
@@ -13,9 +13,7 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
|||||||
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||||
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PencilIcon size={16} />
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
|
||||||
</svg>
|
|
||||||
{isEditing ? 'Done' : ''}
|
{isEditing ? 'Done' : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,63 +1,17 @@
|
|||||||
import Clock from '../Clock';
|
import Clock from '../Clock';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { getTitleSizeClass } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getClockSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-3xl';
|
|
||||||
case 'small':
|
|
||||||
return 'text-4xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-5xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-6xl';
|
|
||||||
default:
|
|
||||||
return 'text-5xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitleSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-4xl';
|
|
||||||
case 'small':
|
|
||||||
return 'text-5xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-6xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-7xl';
|
|
||||||
default:
|
|
||||||
return 'text-6xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-lg';
|
|
||||||
case 'small':
|
|
||||||
return 'text-xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-2xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-3xl';
|
|
||||||
default:
|
|
||||||
return 'text-2xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{config.clock.enabled && (
|
{config.clock.enabled && (
|
||||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
||||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
<Clock config={config} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Config, Wallpaper } from '../../types';
|
import { Config, Wallpaper } from '../../types';
|
||||||
import {
|
import {
|
||||||
addWallpaperToChromeStorageLocal,
|
addWallpaperToChromeStorageLocal,
|
||||||
|
checkChromeStorageLocalAvailable,
|
||||||
removeWallpaperFromChromeStorageLocal,
|
removeWallpaperFromChromeStorageLocal,
|
||||||
} from '../utils/StorageLocalManager';
|
} from '../utils/StorageLocalManager';
|
||||||
|
import { getFileNameFromUrl } from '../utils/urlUtils';
|
||||||
|
|
||||||
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||||
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||||
@@ -43,13 +45,37 @@ const safeParse = (value: string | null): unknown => {
|
|||||||
const toStorageString = (value: unknown): string =>
|
const toStorageString = (value: unknown): string =>
|
||||||
typeof value === 'string' ? value : JSON.stringify(value);
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const deepMerge = <T>(base: T, stored: unknown): T => {
|
||||||
|
if (isPlainObject(base)) {
|
||||||
|
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||||
|
const storedObj = isPlainObject(stored) ? stored : {};
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(storedObj, key)) {
|
||||||
|
result[key] = deepMerge(result[key], storedObj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
if (Array.isArray(base)) {
|
||||||
|
return (Array.isArray(stored) ? stored : base) as T;
|
||||||
|
}
|
||||||
|
if (stored === null) return base;
|
||||||
|
return typeof stored === typeof base ? (stored as T) : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeConfig = (stored: unknown): Config =>
|
||||||
|
deepMerge(DEFAULT_CONFIG, stored);
|
||||||
|
|
||||||
export const ConfigurationService = {
|
export const ConfigurationService = {
|
||||||
loadConfig(): Config {
|
loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('config');
|
const stored = localStorage.getItem('config');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
return { ...DEFAULT_CONFIG, ...parsed };
|
return normalizeConfig(parsed);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing config from localStorage', error);
|
console.error('Error parsing config from localStorage', error);
|
||||||
@@ -76,8 +102,18 @@ export const ConfigurationService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
||||||
const finalName = await addWallpaperToChromeStorageLocal(name, url);
|
const trimmedUrl = url.trim();
|
||||||
return { name: finalName };
|
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> {
|
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||||
@@ -106,6 +142,7 @@ export const ConfigurationService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||||
|
if (wallpaper.url || wallpaper.base64 || !checkChromeStorageLocalAvailable()) return;
|
||||||
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -159,12 +196,12 @@ export const ConfigurationService = {
|
|||||||
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
|
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||||
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||||
.userWallpapers as Wallpaper[];
|
.userWallpapers;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: importedConfig || { ...DEFAULT_CONFIG },
|
config: normalizeConfig(importedConfig),
|
||||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// TypeScript interface for window.chrome
|
// TypeScript interface for window.chrome
|
||||||
|
import { getFileNameFromUrl } from './urlUtils';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
chrome?: {
|
chrome?: {
|
||||||
@@ -18,6 +20,10 @@ declare global {
|
|||||||
|
|
||||||
let isChromeStorageLocalAvailable: boolean | null = null;
|
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.
|
* Checks if chrome.storage.local is available and caches the result.
|
||||||
@@ -32,85 +38,95 @@ export function checkChromeStorageLocalAvailable(): boolean {
|
|||||||
return isChromeStorageLocalAvailable;
|
return isChromeStorageLocalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StorageResult = { [key: string]: string };
|
||||||
|
|
||||||
|
const chromeLocalCall = <T>(
|
||||||
|
operation: (callback: (result: T) => void) => void,
|
||||||
|
): Promise<T> =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
if (!window.chrome?.storage?.local) {
|
||||||
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
operation((result) => {
|
||||||
|
if (window.chrome?.runtime?.lastError) {
|
||||||
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||||
|
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.
|
* 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.
|
* File uploads are stored as base64 while remote wallpapers remain URLs.
|
||||||
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
|
|
||||||
* @param name Wallpaper name (string), used as a fallback.
|
* @param name Wallpaper name (string), used as a fallback.
|
||||||
* @param url Wallpaper image URL (string) or base64 data URL.
|
* @param url Wallpaper image URL (string) or base64 data URL.
|
||||||
* @returns Promise<string> The name under which the wallpaper was stored.
|
* @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> {
|
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
|
||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalName = name.trim();
|
||||||
if (url.startsWith('data:')) {
|
if (url.startsWith('data:')) {
|
||||||
// This is a base64 encoded image from a file upload.
|
if (!finalName) throw new Error('A name is required for an uploaded wallpaper.');
|
||||||
// The name is the file name.
|
} else {
|
||||||
return new Promise<void>((resolve, reject) => {
|
let parsedUrl: URL;
|
||||||
if (window.chrome?.storage?.local) {
|
try {
|
||||||
window.chrome.storage.local.set({ [name]: url }, function () {
|
parsedUrl = new URL(url);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
} catch {
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
throw new Error('Please enter a valid wallpaper URL.');
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
}).then(() => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a URL. Let's try to fetch it.
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch image');
|
|
||||||
const imageBlob = await response.blob();
|
|
||||||
const reader = new FileReader();
|
|
||||||
const base64 = await new Promise<string>((resolve, reject) => {
|
|
||||||
reader.onloadend = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(imageBlob);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If successful, use the filename from URL as the name.
|
|
||||||
const finalName = url.substring(url.lastIndexOf('/') + 1).replace(/[?#].*$/, '') || name;
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
if (window.chrome?.storage?.local) {
|
|
||||||
window.chrome.storage.local.set({ [finalName]: base64 }, function () {
|
|
||||||
if (window.chrome?.runtime?.lastError) {
|
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
}).then(() => finalName);
|
|
||||||
} catch (error) {
|
|
||||||
// If fetch fails (e.g., CORS), store the URL directly with the user-provided name.
|
|
||||||
console.warn('Could not fetch wallpaper, storing URL instead. Error:', error);
|
|
||||||
if (!name) {
|
|
||||||
throw new Error("A name for the wallpaper is required when the URL can't be accessed.");
|
|
||||||
}
|
}
|
||||||
return new Promise<void>((resolve, reject) => {
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
if (window.chrome?.storage?.local) {
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
window.chrome.storage.local.set({ [name]: url }, function () {
|
}
|
||||||
if (window.chrome?.runtime?.lastError) {
|
finalName = finalName || getFileNameFromUrl(parsedUrl);
|
||||||
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()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
return new Promise<string | null>((resolve, reject) => {
|
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||||
if (window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.get([name], cb),
|
||||||
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
|
);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
return result[name] || null;
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve(result[name] || null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
|
|||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
return new Promise<void>((resolve, reject) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.remove(name, cb),
|
||||||
window.chrome.storage.local.remove(name, function () {
|
);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,60 @@
|
|||||||
|
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 {
|
try {
|
||||||
const response = await fetch(url);
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidCachedIcon = (value: string | null): value is string =>
|
||||||
|
typeof value === 'string' && isDataUrl(value);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getWebsiteIcon(rawUrl: string): Promise<string> {
|
||||||
|
let targetUrl = rawUrl.trim();
|
||||||
|
if (targetUrl && !/^https?:\/\//i.test(targetUrl)) {
|
||||||
|
targetUrl = `https://${targetUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
@@ -9,7 +62,7 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
|||||||
if (appleTouchIcon) {
|
if (appleTouchIcon) {
|
||||||
const href = appleTouchIcon.getAttribute('href');
|
const href = appleTouchIcon.getAttribute('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
return new URL(href, url).href;
|
return new URL(href, targetUrl).href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,15 +70,98 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
|||||||
if (iconLink) {
|
if (iconLink) {
|
||||||
const href = iconLink.getAttribute('href');
|
const href = iconLink.getAttribute('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
return new URL(href, url).href;
|
return new URL(href, targetUrl).href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching and parsing HTML for icon:', error);
|
console.error('Error fetching and parsing HTML for icon:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
try {
|
||||||
|
const hostname = new URL(targetUrl).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(iconUrl, { mode: 'cors' });
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Vision Startpage",
|
"name": "Vision Startpage",
|
||||||
"version": "1.0",
|
"version": "0.0.0",
|
||||||
"description": "A beautiful and customizable startpage for your browser.",
|
"description": "A light, modern and customizable startpage.",
|
||||||
"chrome_url_overrides": {
|
"chrome_url_overrides": {
|
||||||
"newtab": "index.html"
|
"newtab": "index.html"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "extension/icons/vision-16.png",
|
||||||
|
"48": "extension/icons/vision-48.png",
|
||||||
|
"128": "extension/icons/vision-128.png"
|
||||||
|
},
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"playwright": "1.61.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
@@ -2387,6 +2388,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"capture:screenshots": "node scripts/capture_screenshots.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"playwright": "1.61.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
project_name: Vision Start
|
project_name: Vision Start
|
||||||
date: 2026-07-03
|
date: 2026-08-11
|
||||||
type: general_overview
|
type: general_overview
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ Live instances / artifacts:
|
|||||||
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
| 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` |
|
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
||||||
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
| 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`.
|
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.
|
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||||
- **Title** — Optional big header title (text + size configurable).
|
- **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.
|
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at a cadence (`1h`–`2d`).
|
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Randomly rotates through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
- 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** — 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.
|
- **Icon library, auto-fetch & cache** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service. Tiles opportunistically cache CORS-readable icon responses as data URLs in `chrome.storage.local` and retain the original URL when caching is unavailable.
|
||||||
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
||||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
||||||
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
||||||
|
|
||||||
Performance notes:
|
Performance notes:
|
||||||
- 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 uses ordinary CORS-enabled `fetch`, 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.
|
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||||
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||||
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||||
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||||
- Icon metadata (`/icon-metadata.json`) is module-level cached, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
- Icon metadata (`/icon-metadata.json`) is fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront. The module-level metadata cache is released when the picker modal unmounts (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||||
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
|
- `Wallpaper` resolves wallpaper URLs through a module-level `Map` bounded to the last 3 entries — so uploaded base64 wallpapers (up to ~4.5MB strings) are not retained in memory for the page's lifetime; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||||
|
- The website icon service keeps an in-memory resolved data-URL cache bounded to 50 entries (FIFO eviction) to avoid unbounded growth from many tiles.
|
||||||
|
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
|
||||||
|
|
||||||
Planned / To-do (tracked in `README.md`):
|
Planned / To-do (tracked in `README.md`):
|
||||||
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
@@ -88,7 +92,6 @@ vision-start/
|
|||||||
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
||||||
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
||||||
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
||||||
├── icon.png # Extension icon source
|
|
||||||
│
|
│
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── Clock.tsx # Header clock widget
|
│ ├── Clock.tsx # Header clock widget
|
||||||
@@ -97,9 +100,11 @@ vision-start/
|
|||||||
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||||
│ ├── CategoryEditModal.tsx # Add/edit a category
|
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||||
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||||
|
│ ├── ModalShell.tsx # Shared centered-modal shell (backdrop, title, footer buttons)
|
||||||
│ ├── ServerWidget.tsx # Bottom server status pill
|
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||||
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||||
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
||||||
|
│ ├── icons.tsx # Shared inline SVG icons (pencil, plus, trash, chevrons)
|
||||||
│ │
|
│ │
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ ├── Header.tsx # Renders Clock + Title
|
│ │ ├── Header.tsx # Renders Clock + Title
|
||||||
@@ -109,30 +114,37 @@ vision-start/
|
|||||||
│ │
|
│ │
|
||||||
│ ├── configuration/
|
│ ├── configuration/
|
||||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||||
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity
|
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
||||||
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||||
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
│ │ ├── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||||
|
│ │ └── RangeSlider.tsx # Shared labeled range slider (with progress fill)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
||||||
│ │
|
│ │
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
│ ├── 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)
|
│ ├── 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/
|
├── public/
|
||||||
│ ├── favicon.ico
|
│ ├── favicon.ico
|
||||||
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
|
│ └── 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/
|
├── 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
|
│ ├── 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
|
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
|
||||||
│
|
│
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
│ ├── 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
|
│ └── 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)
|
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
|
||||||
@@ -152,15 +164,17 @@ vision-start/
|
|||||||
|
|
||||||
## 5. Data Model & State
|
## 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`:
|
The shape of all persisted data lives in `types.ts`:
|
||||||
|
|
||||||
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
|
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
|
||||||
- **`Server`** — `id`, `name`, `address`
|
- **`Server`** — `id`, `name`, `address`
|
||||||
- **`Category`** — `id`, `name`, `websites: Website[]`
|
- **`Category`** — `id`, `name`, `websites: Website[]`
|
||||||
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
||||||
- **`Config`** — Everything else: title, wallpaper list + frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
- **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
||||||
|
|
||||||
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers.
|
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
|
||||||
|
|
||||||
Storage layout (browser-side):
|
Storage layout (browser-side):
|
||||||
|
|
||||||
@@ -168,11 +182,12 @@ Storage layout (browser-side):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `config` | `localStorage` | The full `Config` JSON |
|
| `config` | `localStorage` | The full `Config` JSON |
|
||||||
| `categories` | `localStorage` | `Category[]` JSON |
|
| `categories` | `localStorage` | `Category[]` JSON |
|
||||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
|
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names plus URLs for remote wallpapers) |
|
||||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
| `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -188,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).
|
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
|
||||||
- Optional `<ServerWidget>` if enabled.
|
- Optional `<ServerWidget>` if enabled.
|
||||||
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -209,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.
|
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
||||||
|
|
||||||
### CI/CD (Gitea Actions)
|
### CI/CD (Gitea Actions)
|
||||||
|
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image.
|
||||||
|
- **`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`.
|
- **`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.
|
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
|
||||||
|
|
||||||
@@ -221,11 +240,12 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
## 8. Notable Behaviors & Quirks
|
## 8. Notable Behaviors & Quirks
|
||||||
|
|
||||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, both of which share the `ModalShell` component.
|
||||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
- **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`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **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.
|
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||||
|
- **Website icon cache** is persistent only in `chrome.storage.local`; it is rebuilt from website icon URLs after configuration import. Only CORS-readable `http`/`https` image responses no larger than 256KiB are cached.
|
||||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||||
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||||
@@ -247,10 +267,11 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
| 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` |
|
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
||||||
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
| 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`) |
|
| 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` |
|
| Docker build | `Dockerfile` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-07-03. 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._
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 609 KiB After Width: | Height: | Size: 479 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
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=="
|
||||||
|
}
|
||||||