Compare commits
60 Commits
12ed7e1b9f
...
feat/pipel
| Author | SHA1 | Date | |
|---|---|---|---|
| 552379b2a6 | |||
| f9864072cf | |||
| 51341c33ca | |||
| 8b5c52dd1e | |||
| c3addb6d02 | |||
| 5597afc572 | |||
| 30372f800c | |||
| 9e738cc0d5 | |||
| b60c88e9b1 | |||
| fee538f044 | |||
| 48ec764880 | |||
| babd31548c | |||
| 95ae04ecd2 | |||
| 7a137abb66 | |||
| 023b2ffdc5 | |||
| c9dafd76d1 | |||
| 254d8e26b6 | |||
| c59b7edd82 | |||
| 52d954d9ca | |||
| f35ea01c71 | |||
| 1dfbe74280 | |||
| 1580187689 | |||
| 83dcf65069 | |||
| ddf2f2a416 | |||
| c397e77c01 | |||
| 85b239f540 | |||
| 7efdd17534 | |||
| b1957f2c19 | |||
| efc9e5c3dd | |||
| 65c6946e7f | |||
| 3129fa6531 | |||
| 82da27cf8d | |||
| c4dce04d42 | |||
| c2b3356022 | |||
| d067e0b95c | |||
| aec7a331c6 | |||
| 0d636ab680 | |||
| 69c6c6fe09 | |||
| fd552c48cd | |||
| 95b7be5219 | |||
| b8e1468a46 | |||
| 199d92f733 | |||
| d805531369 | |||
| 8cc17a5a17 | |||
| d4c1884471 | |||
| 377be6e8f6 | |||
| c60cb24dd4 | |||
| 2f3949c2e3 | |||
| 9b818b05f9 | |||
| 008d2321e5 | |||
| 3aff7ffed6 | |||
| 9e80818fc5 | |||
| 140119cb99 | |||
| af7b778561 | |||
| 2849ed3bb2 | |||
| f1c1b0c6c6 | |||
| ffdaf06d55 | |||
| 05263d0d3a | |||
| 905b05e343 | |||
| 181fd3b3ec |
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.DS_Store
|
||||
.claude/
|
||||
99
.gitea/workflows/main.yaml
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Build and Release to Staging
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY_HOST: git.ivanch.me
|
||||
REGISTRY_USERNAME: ivanch
|
||||
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
|
||||
IMAGE_TAG: staging
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Vision Start
|
||||
runs-on: ubuntu-amd64
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup required tools
|
||||
run: sudo apt-get install zip jq curl -y
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run build
|
||||
run: |
|
||||
bash scripts/prepare_release.sh
|
||||
npm run build
|
||||
|
||||
- name: Prepare release
|
||||
run: |
|
||||
mv dist vision-start/
|
||||
mv extension vision-start/
|
||||
mv manifest.json vision-start/
|
||||
|
||||
- name: Create zip archive
|
||||
run: |
|
||||
cd vision-start
|
||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-zip
|
||||
path: vision-start-${{ gitea.ref_name }}.zip
|
||||
|
||||
build_vision_start:
|
||||
name: Build Vision Start Image
|
||||
runs-on: ubuntu-amd64
|
||||
needs: build
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||
| docker login "${{ env.REGISTRY_HOST }}" \
|
||||
-u "${{ env.REGISTRY_USERNAME }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and Push Multi-Arch Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||
|
||||
deploy_vision_start:
|
||||
name: Deploy Vision Start (staging)
|
||||
runs-on: ubuntu-amd64
|
||||
needs: build_vision_start
|
||||
steps:
|
||||
- name: Recreate Container
|
||||
uses: appleboy/ssh-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
key: ${{ secrets.KEY }}
|
||||
port: ${{ secrets.PORT }}
|
||||
script: |
|
||||
cd ${{ secrets.STAGING_DIR }}
|
||||
docker compose pull
|
||||
docker compose up -d --force-recreate
|
||||
178
.gitea/workflows/pull-request.yaml
Normal file
@@ -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 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"
|
||||
220
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,220 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
REGISTRY_HOST: git.ivanch.me
|
||||
REGISTRY_USERNAME: ivanch
|
||||
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
|
||||
IMAGE_TAG: latest
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
zip-file: vision-start-${{ gitea.ref_name }}.zip
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set version from tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Expected a vX.Y.Z tag, got: $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
sed -i -e "s/\"version\": \"0\.0\.0\"/\"version\": \"$VERSION\"/" manifest.json
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Setup required tools
|
||||
run: sudo apt-get install zip jq curl -y
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run build
|
||||
run: |
|
||||
bash scripts/prepare_release.sh
|
||||
npm run build
|
||||
|
||||
- name: Prepare release
|
||||
run: |
|
||||
mv dist vision-start/
|
||||
mv extension vision-start/
|
||||
mv manifest.json vision-start/
|
||||
|
||||
- name: Create zip archive
|
||||
run: |
|
||||
cd vision-start
|
||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-zip
|
||||
path: vision-start-${{ gitea.ref_name }}.zip
|
||||
|
||||
virus-total-check:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
outputs:
|
||||
analysis-url: ${{ steps.vt-check.outputs.analysis-url }}
|
||||
detection-ratio: ${{ steps.vt-check.outputs.detection-ratio }}
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup required tools
|
||||
run: sudo apt-get install jq curl -y
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-zip
|
||||
- name: Run VirusTotal check
|
||||
id: vt-check
|
||||
env:
|
||||
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
||||
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
||||
run: |
|
||||
# Run the VirusTotal check script and capture output in real-time
|
||||
set -o pipefail
|
||||
bash scripts/check_virustotal.sh 2>&1 | tee vt_output.txt
|
||||
|
||||
# Extract analysis URL and detection ratio from output
|
||||
ANALYSIS_URL=$(grep "Analysis URL:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
||||
DETECTION_RATIO=$(grep "Detection ratio:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
|
||||
|
||||
# Set outputs for next job
|
||||
echo "analysis-url=$ANALYSIS_URL" >> $GITEA_OUTPUT
|
||||
echo "detection-ratio=$DETECTION_RATIO" >> $GITEA_OUTPUT
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, virus-total-check, capture_screenshots]
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-zip
|
||||
- name: Download screenshot artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-screenshots
|
||||
path: release-screenshots
|
||||
- name: Release zip
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
body: |
|
||||
This is the release for version ${{ gitea.ref_name }}.
|
||||
|
||||
**Virus Total Analysis URL:** ${{ needs.virus-total-check.outputs.analysis-url }}
|
||||
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
||||
name: ${{ gitea.ref_name }}
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
files: |
|
||||
vision-start-${{ gitea.ref_name }}.zip
|
||||
release-screenshots/home.png
|
||||
release-screenshots/editing.png
|
||||
release-screenshots/configuration.png
|
||||
|
||||
build_vision_start:
|
||||
name: Build Vision Start Image
|
||||
runs-on: ubuntu-amd64
|
||||
needs: [build, virus-total-check]
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set version from tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Expected a vX.Y.Z tag, got: $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
sed -i -e "s/\"version\": \"0\.0\.0\"/\"version\": \"$VERSION\"/" manifest.json
|
||||
|
||||
- name: Log in to Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||
| docker login "${{ env.REGISTRY_HOST }}" \
|
||||
-u "${{ env.REGISTRY_USERNAME }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and Push Multi-Arch Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
|
||||
|
||||
deploy_vision_start:
|
||||
name: Deploy Vision Start (production)
|
||||
runs-on: ubuntu-amd64
|
||||
needs: build_vision_start
|
||||
steps:
|
||||
- name: Recreate Container
|
||||
uses: appleboy/ssh-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
key: ${{ secrets.KEY }}
|
||||
port: ${{ secrets.PORT }}
|
||||
script: |
|
||||
cd ${{ secrets.PROD_DIR }}
|
||||
docker compose pull
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
capture_screenshots:
|
||||
name: Capture Vision Start Screenshots
|
||||
runs-on: ubuntu-amd64
|
||||
needs: deploy_vision_start
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Capture release screenshots
|
||||
env:
|
||||
SCREENSHOT_BASE_URL: http://vision-start.ivanch.me
|
||||
run: npm run capture:screenshots
|
||||
|
||||
- name: Upload release screenshots
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-screenshots
|
||||
retention-days: 30
|
||||
path: |
|
||||
screenshots/home.png
|
||||
screenshots/editing.png
|
||||
screenshots/configuration.png
|
||||
4
.gitignore
vendored
Executable file → Normal file
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.claude/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -22,3 +23,6 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Project specific files
|
||||
public/icon-metadata.json
|
||||
116
AGENTS.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI agents (and humans) working on Vision Start. Read before making changes.
|
||||
|
||||
---
|
||||
|
||||
## 0. First rule: keep `project-context.md` alive
|
||||
|
||||
- **Always read `project-context.md` first.** It is the canonical map of the project: structure, features, data model, flow, build/release. Treat it as required reading before any non-trivial change.
|
||||
- **Always keep it updated.** Whenever you add, remove, rename, or materially change files, modules, features, storage keys, config fields, build/release steps, or workflows, update the relevant section of `project-context.md` in the same change set. If your work touches the structure tree, the data model, the storage layout, or the quick-lookup table, those sections must reflect the new state.
|
||||
- Keep it a general overview, not a coding-style guide — that distinction lives here in `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Styling guidance
|
||||
|
||||
The design language is **soft liquid glass**: lightly translucent matte surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan accents, and iOS-like easing.
|
||||
|
||||
### Surface recipe (use consistently)
|
||||
- **Tiles / floating controls / light surfaces:** `liquid-surface` plus `liquid-control` / `liquid-tile` / `liquid-focus` as appropriate.
|
||||
- **Cards / modals / panels:** `liquid-panel liquid-modal-card rounded-3xl` for centered modals; `liquid-drawer` for the settings drawer.
|
||||
- **Inputs:** `liquid-input p-3`; ranges use `liquid-range`.
|
||||
- **Buttons:** `liquid-button` plus one of `liquid-button-primary`, `liquid-button-success`, `liquid-button-secondary`, or `liquid-button-danger`.
|
||||
- **Add/edit surfaces:** add tiles use `liquid-ghost-tile`; tile edit controls use `liquid-edit-toolbar` and `liquid-edit-action`.
|
||||
- **Full-screen modal overlay:** `liquid-modal-backdrop`.
|
||||
- Keep the glass effect subtle: avoid high-opacity white highlights, heavy blur, strong saturation, or oversized glow unless a specific component needs emphasis.
|
||||
|
||||
### Color tokens
|
||||
- Accent / focus / selection: cyan via `liquid-focus`, `cyan-400`, `cyan-200`, and `liquid-button-primary`
|
||||
- Confirm/Save: `liquid-button-success`
|
||||
- Cancel/secondary: `liquid-button-secondary`
|
||||
- Destructive: `liquid-button-danger` or red text on `liquid-edit-action`
|
||||
- Tertiary (export/import etc.): `liquid-button-secondary`
|
||||
- Status: online `bg-green-400 text-green-400`, offline `bg-red-400 text-red-400`, pending `bg-slate-400 text-slate-400`
|
||||
- Text: primary `text-white`, secondary `text-slate-300`/`text-slate-400`, muted `text-white/50` (in-app hover states)
|
||||
|
||||
### Motion
|
||||
- Use the custom easing tokens defined in `index.css`: `ease-ios` (default), `ease-spring` (toggle knobs, drawer slides), and `ease-liquid` (tile lift and wallpaper transitions).
|
||||
- Standard durations: `duration-150` (buttons), `duration-200` (tiles, toggles, icons), and `duration-300` (drawers/modals).
|
||||
- Prefer the shared liquid classes for hover/press motion. Add custom transforms only when the shared classes do not cover the interaction.
|
||||
- Keep `prefers-reduced-motion` behavior intact when adding animations.
|
||||
|
||||
### Tailwind specifics
|
||||
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`.
|
||||
- `tailwind.config.js` has a **safelist** of `w-[Npx]/h-[Npx]` classes because tile/icon sizes are constructed dynamically. If you add new pixel-size classes that are generated at runtime (not literally present in source), add them to the safelist or they will be purged.
|
||||
- Prefer static classes; only build dynamic class strings when unavoidable and then safelist them.
|
||||
|
||||
### Components with established conventions to reuse
|
||||
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
|
||||
- `ToggleSwitch` (`components/ToggleSwitch.tsx`) for boolean toggles — **always use it** instead of checkboxes.
|
||||
- Modal pattern: `liquid-modal-backdrop` + centered `liquid-panel liquid-modal-card rounded-3xl`, close on overlay click via `handleOverlayClick` (`if (e.target === e.currentTarget) onClose()`).
|
||||
- New editors/settings go in `components/configuration/` as a `*Tab.tsx` and are wired into `ConfigurationModal.tsx`.
|
||||
- New options' size presets follow the existing scale: `tiny | small | medium | large` (see `Header.tsx`, `WebsiteTile.tsx`).
|
||||
|
||||
---
|
||||
|
||||
## 2. New feature guidance
|
||||
|
||||
A "feature" in this project typically means: a new widget, a new settings option, or a new bit of persisted state. When adding one, follow this checklist.
|
||||
|
||||
### A. Types & defaults (in order)
|
||||
1. **`types.ts`** — add new fields to the relevant interface (usually `Config`, sometimes a nested one like `clock`/`serverWidget`). Create a nested object when a feature has 2+ sub-options (mirror the `clock`/`serverWidget` pattern).
|
||||
2. **`components/services/ConfigurationService.ts` `DEFAULT_CONFIG`** — add the new field with a sane default so existing users (with a previously-saved `Config`) get it via the `{ ...DEFAULT_CONFIG, ...parsed }` merge on load.
|
||||
3. Any new standalone collections (not inside `Config`) get their own storage key/helper in `ConfigurationService` (see `userWallpapers`, `wallpaperState` for the pattern).
|
||||
|
||||
### B. Settings UI (if the feature is user-configurable)
|
||||
1. Create `components/configuration/<Feature>Tab.tsx` and add its entry to the `tabs` array in `components/ConfigurationModal.tsx`.
|
||||
2. Receive `config` and an `onChange(updates: Partial<Config>)` callback; emit partial updates — `App.tsx`'s `handleConfigChange` merges shallowly, so update nested objects as whole units (e.g. `{ clock: { ...config.clock, ...updates } }`).
|
||||
3. Use `Dropdown` / `ToggleSwitch` / range inputs per the styling section above.
|
||||
|
||||
### C. Rendering & wiring
|
||||
4. Render the feature in `App.tsx` (or a dedicated component imported by `App.tsx`), gated by its `config.<feature>.enabled` flag when it can be turned off.
|
||||
5. Mark up new modal/edit surfaces to match existing modals (backdrop click closes, glass card styling).
|
||||
|
||||
### D. Persistence — configs must be properly saved
|
||||
6. `App.tsx` already persists `config` via `ConfigurationService.saveConfig(config)` in a `useEffect`. **Any state added to `Config` is persisted automatically** — do not introduce parallel ad-hoc `localStorage` writes for config fields.
|
||||
7. Standalone collections (not in `Config`) need explicit save calls — mirror `userWallpapers`: load on mount, save on every mutation, never keep stale copies.
|
||||
8. Never write to `chrome.storage.local` directly outside `components/utils/StorageLocalManager.ts`. Always check `checkChromeStorageLocalAvailable()` first and gracefully degrade (the app must still run as a plain web page). Throw informative errors when the storage is required but missing.
|
||||
|
||||
### E. Export / import must keep working
|
||||
9. If you add a new `localStorage` key (other than config fields, which are exported/imported automatically), add it to `REQUIRED_LOCAL_STORAGE_KEYS` in `ConfigurationService.ts` so it is included in exports and restored on imports.
|
||||
10. After adding fields to `Config`, double check the import path: `importConfig` writes each restored key to `localStorage` and returns `{ config, userWallpapers }`. New config sub-objects flow through automatically because they're inside `config`.
|
||||
|
||||
### F. Defaults & migrations
|
||||
11. Default values must be chosen so a fresh install and an upgrade from an older `Config` both behave sensibly (the load path merges onto `DEFAULT_CONFIG`).
|
||||
12. If a new field changes behavior in a way that existing users' stored data should be preserved differently, handle the migration inside `loadConfig` (and consider bumping the export `version` field in `exportConfig` if the shape changes non-additively).
|
||||
|
||||
### G. Style of new tiles/widgets
|
||||
13. A new widget should follow the existing widget shape: optional/gated by `config.<feature>.enabled`, positioned with a liquid glass container, and use the surface/duration/easing tokens above.
|
||||
14. New "editable" tiles/content reuse the `isEditing` mode and the edit/move/delete affordances pattern from `WebsiteTile.tsx` and `CategoryGroup.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 3. General engineering rules
|
||||
|
||||
- **Language:** TypeScript strict mode (`tsconfig.json` enforces `strict`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`). Do not loosen these.
|
||||
- **Runtime note:** Preact is the actual runtime (`@preact/preset-vite`), but types and imports use `react`/`@types/react`. Do not introduce React-only APIs without verifying Preact compatibility.
|
||||
- **Path alias:** `@/*` maps to the project root (see `tsconfig.json` `paths`). Source files currently use relative imports (`./`, `../`) — match the surrounding file.
|
||||
- **No comments** in committed code unless explicitly requested.
|
||||
- **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. The only npm scripts are `dev`, `build`, `preview`.
|
||||
- **No lint/typecheck script is configured.** Verify TS correctness manually (e.g. `npx tsc --noEmit`), and confirm `npm run build` succeeds before considering a task complete.
|
||||
- **Don't touch `public/icon-metadata.json`** — it's gitignored and fetched at release-build time by `scripts/prepare_release.sh`. Don't commit a local copy.
|
||||
- **Don't reintroduce dead code:** `components/EditModal.tsx` imports `lucide-react` and `./IconPicker`, neither of which is a dependency. It is not wired into the app. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, and add the icon picker inline (as in `WebsiteEditModal.tsx`) rather than reviving `IconPicker`.
|
||||
- **External asset URLs:** any new icons/css/JS pulled from CDNs (e.g. `cdn.jsdelivr.net`) must work offline-or-not without crashing — always provide a fallback (see how `iconService.ts` falls back to Google's S2 favicon service).
|
||||
- **Manifest & extension constraints:** the extension uses Manifest V3 with `script-src 'self'` CSP and only the `storage` permission. Don't introduce inline scripts/eval, and avoid requesting new permissions unless essential — added permissions can disable updates on installed extensions.
|
||||
- **Commits:** never commit unless explicitly asked. Don't touch secrets, don't edit `.gitignore`/`.env.local`/`.claude/`, don't run `git config`.
|
||||
|
||||
---
|
||||
|
||||
## 4. When in doubt
|
||||
|
||||
- Read `project-context.md`.
|
||||
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
|
||||
- Favor the established liquid utilities (`liquid-surface`, `liquid-panel`, `liquid-input`, `liquid-button`, `liquid-focus`) over ad-hoc glass class strings.
|
||||
- Surface failing assumptions (CORS, missing chrome.storage, larger-than-4MB wallpapers) with clear errors, matching the style of `StorageLocalManager.ts` and `iconService.ts`.
|
||||
- Update `project-context.md` after your change.
|
||||
381
App.tsx
Executable file → Normal file
@@ -1,39 +1,42 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import WebsiteTile from './components/WebsiteTile';
|
||||
import ConfigurationModal from './components/ConfigurationModal';
|
||||
import Clock from './components/Clock';
|
||||
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
||||
import ServerWidget from './components/ServerWidget';
|
||||
import { DEFAULT_CATEGORIES } from './constants';
|
||||
import { Category, Website, Wallpaper } from './types';
|
||||
import Dropdown from './components/Dropdown';
|
||||
import WebsiteEditModal from './components/WebsiteEditModal';
|
||||
import CategoryEditModal from './components/CategoryEditModal';
|
||||
import { PlusCircle, Pencil } from 'lucide-react';
|
||||
import { baseWallpapers } from './components/utils/baseWallpapers';
|
||||
import { Category, Website, Config } from './types';
|
||||
import Header from './components/layout/Header';
|
||||
import EditButton from './components/layout/EditButton';
|
||||
import ConfigurationButton from './components/layout/ConfigurationButton';
|
||||
import CategoryGroup from './components/layout/CategoryGroup';
|
||||
import Wallpaper from './components/Wallpaper';
|
||||
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||
|
||||
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||
|
||||
const defaultConfig = {
|
||||
title: 'Vision Start',
|
||||
subtitle: 'Your personal portal to the web.',
|
||||
backgroundUrl: 'https://i.imgur.com/C6ynAtX.jpeg',
|
||||
wallpaperBlur: 0,
|
||||
wallpaperBrightness: 100,
|
||||
wallpaperOpacity: 100,
|
||||
titleSize: 'medium',
|
||||
subtitleSize: 'medium',
|
||||
alignment: 'middle',
|
||||
horizontalAlignment: 'middle',
|
||||
clock: {
|
||||
enabled: true,
|
||||
size: 'medium',
|
||||
font: 'Helvetica',
|
||||
format: 'h:mm A',
|
||||
},
|
||||
serverWidget: {
|
||||
enabled: false,
|
||||
pingFrequency: 15,
|
||||
servers: [],
|
||||
},
|
||||
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 = () => {
|
||||
@@ -54,41 +57,58 @@ const App: React.FC = () => {
|
||||
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||
const [config, setConfig] = useState(() => {
|
||||
try {
|
||||
const storedConfig = localStorage.getItem('config');
|
||||
if (storedConfig) {
|
||||
return { ...defaultConfig, ...JSON.parse(storedConfig) };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing config from localStorage', error);
|
||||
}
|
||||
return { ...defaultConfig };
|
||||
});
|
||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>(() => {
|
||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
||||
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
|
||||
});
|
||||
|
||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||
const selectedWallpaper = allWallpapers.find(w => w.url === config.backgroundUrl || w.base64 === config.backgroundUrl);
|
||||
const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig());
|
||||
const [wallpaperVersion, setWallpaperVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('categories', JSON.stringify(categories));
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
}, [categories, config]);
|
||||
ConfigurationService.saveConfig(config);
|
||||
}, [config]);
|
||||
|
||||
const handleSaveConfig = (newConfig: any) => {
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('categories', JSON.stringify(categories));
|
||||
} catch (error) {
|
||||
console.error('Error saving categories to localStorage', error);
|
||||
}
|
||||
}, [categories]);
|
||||
|
||||
const handleSaveConfig = useCallback((newConfig: Config) => {
|
||||
setConfig(newConfig);
|
||||
setIsConfigModalOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSaveWebsite = (website: Partial<Website>) => {
|
||||
const handleWallpaperChange = useCallback((newConfig: Partial<Config>) => {
|
||||
setConfig(prev => ({ ...prev, ...newConfig }));
|
||||
}, []);
|
||||
|
||||
const handleNextWallpaper = useCallback(() => {
|
||||
const names = config.currentWallpapers;
|
||||
if (names.length === 0) return;
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
|
||||
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
|
||||
const nextIndex = (safeCurrent + 1) % names.length;
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: new Date().toISOString(),
|
||||
currentIndex: nextIndex,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error advancing wallpaper state', error);
|
||||
}
|
||||
setWallpaperVersion(v => v + 1);
|
||||
}, [config.currentWallpapers]);
|
||||
|
||||
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
|
||||
if (editingWebsite) {
|
||||
const idToUpdate = website.id ?? editingWebsite.id;
|
||||
const newCategories = categories.map(category => ({
|
||||
...category,
|
||||
websites: category.websites.map(w =>
|
||||
w.id === website.id ? { ...w, ...website } : w
|
||||
w.id === idToUpdate ? { ...w, ...website, id: idToUpdate } : w
|
||||
),
|
||||
}));
|
||||
setCategories(newCategories);
|
||||
@@ -109,9 +129,9 @@ const App: React.FC = () => {
|
||||
setCategories(newCategories);
|
||||
setAddingWebsite(null);
|
||||
}
|
||||
};
|
||||
}, [editingWebsite, addingWebsite, categories]);
|
||||
|
||||
const handleSaveCategory = (name: string) => {
|
||||
const handleSaveCategory = useCallback((name: string) => {
|
||||
if (editingCategory) {
|
||||
const newCategories = categories.map(category =>
|
||||
category.id === editingCategory.id ? { ...category, name } : category
|
||||
@@ -127,9 +147,9 @@ const App: React.FC = () => {
|
||||
}
|
||||
setEditingCategory(null);
|
||||
setIsCategoryModalOpen(false);
|
||||
};
|
||||
}, [editingCategory, categories]);
|
||||
|
||||
const handleDeleteWebsite = () => {
|
||||
const handleDeleteWebsite = useCallback(() => {
|
||||
if (!editingWebsite) return;
|
||||
|
||||
const newCategories = categories.map(category => ({
|
||||
@@ -138,239 +158,95 @@ const App: React.FC = () => {
|
||||
}));
|
||||
setCategories(newCategories);
|
||||
setEditingWebsite(null);
|
||||
};
|
||||
}, [editingWebsite, categories]);
|
||||
|
||||
const handleDeleteCategory = () => {
|
||||
const handleDeleteCategory = useCallback(() => {
|
||||
if (!editingCategory) return;
|
||||
|
||||
const newCategories = categories.filter(c => c.id !== editingCategory.id);
|
||||
setCategories(newCategories);
|
||||
setEditingCategory(null);
|
||||
setIsCategoryModalOpen(false);
|
||||
};
|
||||
}, [editingCategory, categories]);
|
||||
|
||||
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => {
|
||||
const categoryIndex = categories.findIndex(c => c.id === website.categoryId);
|
||||
const handleMoveWebsite = useCallback((website: Website, direction: 'left' | 'right') => {
|
||||
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
|
||||
if (categoryIndex === -1) return;
|
||||
|
||||
const category = categories[categoryIndex];
|
||||
const websiteIndex = category.websites.findIndex(w => w.id === website.id);
|
||||
if (websiteIndex === -1) return;
|
||||
|
||||
const newCategories = [...categories];
|
||||
const targetIndex = direction === 'left' ? websiteIndex - 1 : websiteIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= category.websites.length) return;
|
||||
|
||||
const newWebsites = [...category.websites];
|
||||
const [movedWebsite] = newWebsites.splice(websiteIndex, 1);
|
||||
newWebsites.splice(targetIndex, 0, movedWebsite);
|
||||
|
||||
if (direction === 'left') {
|
||||
const newCategoryIndex = (categoryIndex - 1 + categories.length) % categories.length;
|
||||
const newCategories = [...categories];
|
||||
newCategories[categoryIndex] = { ...category, websites: newWebsites };
|
||||
const destCategory = newCategories[newCategoryIndex];
|
||||
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
|
||||
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
|
||||
} else {
|
||||
const newCategoryIndex = (categoryIndex + 1) % categories.length;
|
||||
newCategories[categoryIndex] = { ...category, websites: newWebsites };
|
||||
const destCategory = newCategories[newCategoryIndex];
|
||||
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
|
||||
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
|
||||
}
|
||||
|
||||
setCategories(newCategories);
|
||||
};
|
||||
|
||||
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 getClockSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-3xl';
|
||||
case 'small':
|
||||
return 'text-4xl';
|
||||
case 'medium':
|
||||
return 'text-5xl';
|
||||
case 'large':
|
||||
return 'text-6xl';
|
||||
default:
|
||||
return 'text-5xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitleSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-4xl';
|
||||
case 'small':
|
||||
return 'text-5xl';
|
||||
case 'medium':
|
||||
return 'text-6xl';
|
||||
case 'large':
|
||||
return 'text-7xl';
|
||||
default:
|
||||
return 'text-6xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getSubtitleSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-lg';
|
||||
case 'small':
|
||||
return 'text-xl';
|
||||
case 'medium':
|
||||
return 'text-2xl';
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getHorizontalAlignmentClass = (alignment: string) => {
|
||||
switch (alignment) {
|
||||
case 'left':
|
||||
return 'justify-start';
|
||||
case 'middle':
|
||||
return 'justify-center';
|
||||
case 'right':
|
||||
return 'justify-end';
|
||||
default:
|
||||
return 'justify-center';
|
||||
}
|
||||
};
|
||||
}, [categories]);
|
||||
|
||||
return (
|
||||
|
||||
<main
|
||||
className={`min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} p-4`}
|
||||
className={`vision-shell min-h-screen w-full flex flex-col items-center ${getAlignmentClass(config.alignment)} px-4 py-8 sm:px-6 sm:py-10`}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10"
|
||||
style={{
|
||||
backgroundImage: `url('${selectedWallpaper?.url || selectedWallpaper?.base64 || ''}')`,
|
||||
filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`,
|
||||
opacity: `${config.wallpaperOpacity}%`,
|
||||
}}
|
||||
></div>
|
||||
<div className="absolute top-4 left-4">
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
{isEditing ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4">
|
||||
<button
|
||||
onClick={() => setIsConfigModalOpen(true)}
|
||||
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white hover:bg-white/25 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-gear-wide" viewBox="0 0 16 16">
|
||||
<path d="M8.932.727c-.243-.97-1.62-.97-1.864 0l-.071.286a.96.96 0 0 1-1.622.434l-.205-.211c-.695-.719-1.888-.03-1.613.931l.08.284a.96.96 0 0 1-1.186 1.187l-.284-.081c-.96-.275-1.65.918-.931 1.613l.211.205a.96.96 0 0 1-.434 1.622l-.286.071c-.97.243-.97 1.62 0 1.864l.286.071a.96.96 0 0 1 .434 1.622l-.211.205c-.719.695-.03 1.888.931 1.613l.284-.08a.96.96 0 0 1 1.187 1.187l-.081.283c-.275.96.918 1.65 1.613.931l.205-.211a.96.96 0 0 1 1.622.434l.071.286c.243.97 1.62.97 1.864 0l.071-.286a.96.96 0 0 1 1.622-.434l.205.211c.695.719 1.888.03 1.613-.931l-.08-.284a.96.96 0 0 1 1.187-1.187l.283.081c.96.275 1.65-.918.931-1.613l-.211-.205a.96.96 0 0 1 .434-1.622l.286-.071c.97-.243.97-1.62 0-1.864l-.286-.071a.96.96 0 0 1-.434-1.622l.211-.205c.719-.695.03-1.888-.931-1.613l-.284.08a.96.96 0 0 1-1.187-1.186l.081-.284c.275-.96-.918-1.65-1.613-.931l-.205.211a.96.96 0 0 1-1.622-.434zM8 12.997a4.998 4.998 0 1 1 0-9.995 4.998 4.998 0 0 1 0 9.996z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<Wallpaper
|
||||
wallpaperNames={config.currentWallpapers}
|
||||
blur={config.wallpaperBlur}
|
||||
brightness={config.wallpaperBrightness}
|
||||
opacity={config.wallpaperOpacity}
|
||||
wallpaperFrequency={config.wallpaperFrequency}
|
||||
wallpaperVersion={wallpaperVersion}
|
||||
/>
|
||||
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
|
||||
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
|
||||
|
||||
{/* Absolute top-center Clock */}
|
||||
{config.clock.enabled && (
|
||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
|
||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
||||
</div>
|
||||
)}
|
||||
<Header config={config} />
|
||||
|
||||
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||
{(config.title || config.subtitle) && (
|
||||
<div className="text-center">
|
||||
<h1
|
||||
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
|
||||
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
{config.title}
|
||||
</h1>
|
||||
<p
|
||||
className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
|
||||
style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
{config.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 w-full mt-16">
|
||||
<div className="relative z-10 flex flex-col gap-7 sm:gap-8 w-full mt-12 sm:mt-14">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="w-full">
|
||||
<div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
|
||||
<h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCategory(category);
|
||||
setIsCategoryModalOpen(true);
|
||||
}}
|
||||
className="ml-2 text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
|
||||
{category.websites.map((website) => (
|
||||
<WebsiteTile
|
||||
key={website.id}
|
||||
website={website}
|
||||
<CategoryGroup
|
||||
key={category.id}
|
||||
category={category}
|
||||
isEditing={isEditing}
|
||||
onEdit={setEditingWebsite}
|
||||
onMove={handleMoveWebsite}
|
||||
setEditingCategory={setEditingCategory}
|
||||
setIsCategoryModalOpen={setIsCategoryModalOpen}
|
||||
setAddingWebsite={setAddingWebsite}
|
||||
setEditingWebsite={setEditingWebsite}
|
||||
handleMoveWebsite={handleMoveWebsite}
|
||||
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
||||
horizontalAlignment={config.horizontalAlignment}
|
||||
tileSize={config.tileSize}
|
||||
/>
|
||||
))}
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => setAddingWebsite(category)}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
>
|
||||
<PlusCircle size={48} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isEditing && (
|
||||
<div className="flex justify-center">
|
||||
<div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCategory(null);
|
||||
setIsCategoryModalOpen(true);
|
||||
}}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
||||
aria-label="Add category"
|
||||
>
|
||||
<PlusCircle size={48} />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
Add category
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.serverWidget.enabled && (
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<ServerWidget config={config} />
|
||||
</div>
|
||||
)}
|
||||
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
||||
|
||||
{(editingWebsite || addingWebsite) && (
|
||||
<Suspense fallback={null}>
|
||||
<WebsiteEditModal
|
||||
website={editingWebsite || undefined}
|
||||
edit={!!editingWebsite}
|
||||
@@ -381,9 +257,11 @@ const App: React.FC = () => {
|
||||
onSave={handleSaveWebsite}
|
||||
onDelete={handleDeleteWebsite}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{isCategoryModalOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CategoryEditModal
|
||||
category={editingCategory || undefined}
|
||||
edit={!!editingCategory}
|
||||
@@ -394,14 +272,19 @@ const App: React.FC = () => {
|
||||
onSave={handleSaveCategory}
|
||||
onDelete={handleDeleteCategory}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{isConfigModalOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ConfigurationModal
|
||||
currentConfig={config}
|
||||
onClose={() => setIsConfigModalOpen(false)}
|
||||
onSave={handleSaveConfig}
|
||||
onWallpaperChange={handleWallpaperChange}
|
||||
onNextWallpaper={handleNextWallpaper}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN sh scripts/prepare_release.sh
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY manifest.json /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/conf.d/gzip.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
21
LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Vision Startpage Project
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
60
PRIVACY.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Privacy Policy for Vision Start
|
||||
|
||||
This Privacy Policy describes how the Vision Start Chrome extension handles user information.
|
||||
|
||||
## Information Stored by the Extension
|
||||
|
||||
Vision Start stores extension settings and user-provided configuration locally in the browser using Chrome's local extension storage.
|
||||
|
||||
This information may include:
|
||||
* Start-page preferences and appearance settings;
|
||||
* Bookmarks, shortcuts, or server and services addresses added by the user;
|
||||
* Configuration for features enabled by the user.
|
||||
|
||||
This information is used only to provide the extension's functionality. It is not transmitted to, collected by, or stored on servers operated by the developer.
|
||||
|
||||
The developer does not have access to information stored locally by the extension.
|
||||
|
||||
## Server Status Requests
|
||||
|
||||
Vision Start can send network requests to server addresses configured by the user in order to check whether those servers are available.
|
||||
|
||||
These requests are sent directly from the user's browser to the configured server. The developer does not receive, proxy, log, or store these requests.
|
||||
|
||||
The destination server may receive standard network information associated with the request, such as the user's IP address, browser request headers, and the requested server address. The handling of that information is controlled by the operator of the destination server.
|
||||
|
||||
## External Favicon Service
|
||||
|
||||
Vision Start uses the Google Favicon Service (https://www.google.com/s2/favicons) to retrieve icons for websites configured by the user.
|
||||
|
||||
When an icon is requested, the relevant website domain is sent directly from the user's browser to Google. Google may also receive standard information associated with the network request, such as the user's IP address, browser request headers, and the requested domain.
|
||||
|
||||
The developer does not receive, proxy, log, or store these requests. Google's handling of information is governed by the Google Privacy Policy.
|
||||
|
||||
## Data Sharing and Sale
|
||||
|
||||
The developer does not sell, rent, trade, or use user information for advertising, analytics, profiling, or marketing.
|
||||
|
||||
No locally stored extension data is shared with the developer or with third parties, except for the direct network requests described above that are necessary to provide user-requested features.
|
||||
|
||||
## Data Retention and Deletion
|
||||
|
||||
Information stored by Vision Start remains in the browser until the user:
|
||||
|
||||
* Deletes the relevant information through the extension;
|
||||
* Clears the extension's browser storage; or
|
||||
* Uninstalls the extension.
|
||||
|
||||
## Security
|
||||
|
||||
Vision Start is designed to keep user configuration on the user's device. Users should use HTTPS addresses whenever possible when configuring remote servers or external resources.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
This Privacy Policy may be updated if the extension's functionality or data-handling practices change.
|
||||
|
||||
Any updated version will be published at this location with a revised effective date. Material changes to how user information is handled will also be disclosed through the extension or its Chrome Web Store listing where appropriate.
|
||||
|
||||
## Contact
|
||||
|
||||
Questions about this Privacy Policy may be submitted through the developer's GitHub profile: https://github.com/ivanch
|
||||
101
README.md
Executable file → Normal file
@@ -1,17 +1,96 @@
|
||||
# Vision Start
|
||||
#### Small startpage
|
||||
<div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
|
||||
<img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
|
||||
</div>
|
||||
|
||||
## Predefined themes
|
||||
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
||||
A light, modern and customizable startpage built with React.
|
||||
</div>
|
||||
|
||||
1. Abstract
|
||||
2. Aurora (Vista vibes)
|
||||
3. Mountain
|
||||
<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>
|
||||
|
||||
## Run Locally
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Installing
|
||||
|
||||
Vision Start is not yet available on Chrome Web Store, but it can be installed manually:
|
||||
1. Go to https://git.ivanch.me/ivanch/vision-start/releases/latest
|
||||
2. Download the latest `vision-start-[version].zip` file
|
||||
3. Extract the zip file, you will have a `vision-start` folder
|
||||
4. Go to chrome://extensions/
|
||||
5. Enable "Developer mode" in the top right corner
|
||||
6. Click on "Load unpacked" and select the `vision-start` folder you extracted in step 3
|
||||
7. The extension should now be installed! Just open a new tab to see it in action.
|
||||
|
||||
## Features
|
||||
|
||||
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
|
||||
* **Elegant Clock:** A clock because all startpages have one.
|
||||
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
||||
* **Liquid Glass UI:** A modern, light interface with frosted surfaces, soft highlights, and gentle motion.
|
||||
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
||||
* **Settings:** A settings page to configure the startpage, with export/import functionality.
|
||||
|
||||
## Backgrounds
|
||||
|
||||
It comes with a selection of some nice pre-defined backgrounds: **Abstract**, **Abstract Red**, **Beach**, **Dark**, **Mountain**, **Waves**.
|
||||
|
||||
You can also upload your own images on it (or fetch it from the web).
|
||||
|
||||
## Running Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Run the app:
|
||||
`npm run dev`
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://gitea.com/ivan/vision-start.git
|
||||
cd vision-start
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## To-do
|
||||
|
||||
* [x] Multiple Wallpapers
|
||||
* [x] Remake icons
|
||||
* [/] Increase offline compatibility (might not be possible)
|
||||
- [x] Use chrome.storage.local for user wallpapers -- this one is
|
||||
- [ ] Use chrome.storage.local for some logos -- a bit hard
|
||||
- Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
|
||||
* Dynamic Weather Widget
|
||||
* A box with information about the current weather, with manual entry on the location
|
||||
* Display current temperature, weather condition (e.g., "Sunny," "Cloudy"), and a corresponding icon
|
||||
* Optionally, show a 3-day forecast when clicked or hovered
|
||||
* Search Bar Widget
|
||||
* Positioned to the right or left side of the clock, display a nice search bar
|
||||
* Behaviour:
|
||||
* When not in focus, it could be highly transparent with just a faint border and a search icon.
|
||||
* When clicked, it would smoothly expand and become slightly more opaque, with a soft glow around the border (similar to the existing ones)
|
||||
* Config to allow changing the default search engine
|
||||
* Draggable & Resizable Grid System
|
||||
* Allow users to drag and drop all widgets (Clock, Website Tiles, Weather, Title, etc.) into any position on a grid
|
||||
* Notes / Scratchpad Widget
|
||||
* A simple text area that saves its content to local storage automatically.
|
||||
* Maybe some extra formatting (bold, italic, increase font size, etc).
|
||||
* Theme-ing
|
||||
* A Light/Dark Mode toggle
|
||||
* Custom Accent Colors
|
||||
* Selection of 6-8 accent colors that are guaranteed to look good with both Light and Dark themes
|
||||
* Define CSS variables for the accent color
|
||||
* Dynamic Wallpaper-Based Theming
|
||||
* Automatically adapt the UI's accent color to match the current wallpaper
|
||||
* Minimal feel toggle
|
||||
* Disable title and search widget
|
||||
* Tiles become small stylish lines
|
||||
|
||||
From a technical side:
|
||||
* Refactor everything :(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Category } from '../types';
|
||||
|
||||
interface CategoryEditModalProps {
|
||||
@@ -12,10 +12,6 @@ interface CategoryEditModalProps {
|
||||
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
||||
const [name, setName] = useState(category ? category.name : '');
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(name);
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
@@ -23,31 +19,31 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
||||
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
|
||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Category Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
{edit && (
|
||||
<button onClick={onDelete} className="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<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-4">
|
||||
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<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="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,26 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
||||
const [time, setTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(timerId);
|
||||
}, []);
|
||||
if (!config.clock.enabled) return;
|
||||
|
||||
const scheduleNext = () => {
|
||||
const now = new Date();
|
||||
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setTime(new Date());
|
||||
intervalId = window.setInterval(() => setTime(new Date()), 60_000);
|
||||
}, msToNextMinute);
|
||||
};
|
||||
|
||||
let timeoutId = 0;
|
||||
let intervalId = 0;
|
||||
scheduleNext();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [config.clock.enabled]);
|
||||
|
||||
if (!config.clock.enabled) {
|
||||
return null;
|
||||
@@ -39,9 +56,8 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`text-white font-bold ${getClockSizeClass(config.clock.size)}`}
|
||||
className={`liquid-clock-text whitespace-nowrap text-white font-bold ${getClockSizeClass(config.clock.size)}`}
|
||||
style={{
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
||||
fontFamily: config.clock.font,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,624 +1,237 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { Server, Wallpaper } from '../types';
|
||||
import { Trash } from 'lucide-react';
|
||||
import Dropdown from './Dropdown';
|
||||
import { Config, Wallpaper } from '../types';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||
import { ConfigurationService } from './services/ConfigurationService';
|
||||
import GeneralTab from './configuration/GeneralTab';
|
||||
import ThemeTab from './configuration/ThemeTab';
|
||||
import ClockTab from './configuration/ClockTab';
|
||||
import ServerWidgetTab from './configuration/ServerWidgetTab';
|
||||
|
||||
interface ConfigurationModalProps {
|
||||
onClose: () => void;
|
||||
onSave: (config: any) => void;
|
||||
currentConfig: any;
|
||||
onSave: (config: Config) => void;
|
||||
currentConfig: Config;
|
||||
onWallpaperChange: (newConfig: Partial<Config>) => void;
|
||||
onNextWallpaper: () => void;
|
||||
}
|
||||
|
||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig }) => {
|
||||
const [config, setConfig] = useState({
|
||||
...currentConfig,
|
||||
titleSize: currentConfig.titleSize || 'medium',
|
||||
subtitleSize: currentConfig.subtitleSize || 'medium',
|
||||
alignment: currentConfig.alignment || 'middle',
|
||||
tileSize: currentConfig.tileSize || 'medium',
|
||||
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
|
||||
wallpaperBlur: currentConfig.wallpaperBlur || 0,
|
||||
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
|
||||
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
|
||||
serverWidget: {
|
||||
enabled: false,
|
||||
pingFrequency: 15,
|
||||
servers: [],
|
||||
...currentConfig.serverWidget,
|
||||
},
|
||||
clock: {
|
||||
enabled: true,
|
||||
size: 'medium',
|
||||
font: 'Helvetica',
|
||||
format: 'h:mm A',
|
||||
...currentConfig.clock,
|
||||
},
|
||||
});
|
||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
onClose,
|
||||
onSave,
|
||||
currentConfig,
|
||||
onWallpaperChange,
|
||||
onNextWallpaper,
|
||||
}) => {
|
||||
const [config, setConfig] = useState<Config>(currentConfig);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerAddress, setNewServerAddress] = useState('');
|
||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const isSaving = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
||||
if (storedUserWallpapers) {
|
||||
setUserWallpapers(JSON.parse(storedUserWallpapers));
|
||||
}
|
||||
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
||||
setUserWallpapers(ConfigurationService.loadUserWallpapers());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A small timeout to allow the component to mount before starting the transition
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 10);
|
||||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!isSaving.current) {
|
||||
onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
||||
ConfigurationService.resetWallpaperState();
|
||||
}, [config.currentWallpapers]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 300); // This duration should match the transition duration
|
||||
setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
if (name.startsWith('serverWidget.')) {
|
||||
const field = name.split('.')[1];
|
||||
setConfig({
|
||||
...config,
|
||||
serverWidget: { ...config.serverWidget, [field]: value },
|
||||
});
|
||||
} else if (name.startsWith('clock.')) {
|
||||
const field = name.split('.')[1];
|
||||
setConfig({
|
||||
...config,
|
||||
clock: { ...config.clock, [field]: value },
|
||||
});
|
||||
} else {
|
||||
setConfig({ ...config, [name]: value });
|
||||
const handleConfigChange = (updates: Partial<Config>) => {
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const handleAddWallpaper = async (name: string, url: string) => {
|
||||
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddWallpaperFile = async (file: File) => {
|
||||
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
||||
const updated = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||
try {
|
||||
await ConfigurationService.deleteWallpaper(wallpaper);
|
||||
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
|
||||
setUserWallpapers(updated);
|
||||
ConfigurationService.saveUserWallpapers(updated);
|
||||
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
|
||||
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
|
||||
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
|
||||
} catch (error) {
|
||||
alert('Error deleting wallpaper. Please try again.');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClockToggleChange = (checked: boolean) => {
|
||||
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
|
||||
};
|
||||
|
||||
const handleServerWidgetToggleChange = (checked: boolean) => {
|
||||
setConfig({
|
||||
...config,
|
||||
serverWidget: { ...config.serverWidget, enabled: checked },
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddServer = () => {
|
||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||
|
||||
const newServer: Server = {
|
||||
id: Date.now().toString(),
|
||||
name: newServerName,
|
||||
address: newServerAddress,
|
||||
};
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
serverWidget: {
|
||||
...config.serverWidget,
|
||||
servers: [...config.serverWidget.servers, newServer],
|
||||
},
|
||||
});
|
||||
|
||||
setNewServerName('');
|
||||
setNewServerAddress('');
|
||||
};
|
||||
|
||||
const handleRemoveServer = (id: string) => {
|
||||
setConfig({
|
||||
...config,
|
||||
serverWidget: {
|
||||
...config.serverWidget,
|
||||
servers: config.serverWidget.servers.filter((server: Server) => server.id !== id),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = (result: any) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(config.serverWidget.servers);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
serverWidget: {
|
||||
...config.serverWidget,
|
||||
servers: items,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddWallpaper = () => {
|
||||
if (newWallpaperName.trim() === '' || newWallpaperUrl.trim() === '') return;
|
||||
|
||||
const newWallpaper: Wallpaper = {
|
||||
name: newWallpaperName,
|
||||
url: newWallpaperUrl,
|
||||
};
|
||||
|
||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
||||
setUserWallpapers(updatedUserWallpapers);
|
||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||
setConfig({ ...config, backgroundUrl: newWallpaperUrl });
|
||||
|
||||
setNewWallpaperName('');
|
||||
setNewWallpaperUrl('');
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 4 * 1024 * 1024) {
|
||||
alert('File size exceeds 4MB. Please choose a smaller file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = reader.result as string;
|
||||
if (base64.length > 4.5 * 1024 * 1024) {
|
||||
alert('The uploaded image is too large. Please choose a smaller file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUserWallpapers = userWallpapers.filter(w => !w.base64);
|
||||
const newWallpaper: Wallpaper = {
|
||||
name: file.name,
|
||||
base64,
|
||||
};
|
||||
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
|
||||
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
|
||||
setConfig({ ...config, backgroundUrl: base64 });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
|
||||
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
|
||||
setUserWallpapers(updatedUserWallpapers);
|
||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
||||
|
||||
if (config.backgroundUrl === (wallpaper.url || wallpaper.base64)) {
|
||||
const nextWallpaper = baseWallpapers[0] || updatedUserWallpapers[0];
|
||||
if (nextWallpaper) {
|
||||
setConfig({ ...config, backgroundUrl: nextWallpaper.url || nextWallpaper.base64 });
|
||||
}
|
||||
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const { config: importedConfig, userWallpapers: importedWallpapers } =
|
||||
await ConfigurationService.importConfig(file);
|
||||
setConfig(importedConfig);
|
||||
setUserWallpapers(importedWallpapers);
|
||||
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
|
||||
onSave(importedConfig);
|
||||
alert('Configuration imported successfully. The page will reload to apply all data.');
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert('Could not import configuration. Please use a valid export JSON file.');
|
||||
console.error(error);
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'theme', label: 'Theme' },
|
||||
{ id: 'clock', label: 'Clock' },
|
||||
{ id: 'serverWidget', label: 'Server Widget' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300 ease-in-out ${
|
||||
className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={handleClose}
|
||||
></div>
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`fixed top-0 right-0 h-full w-full max-w-lg bg-black/50 backdrop-blur-xl border-l border-white/10 text-white flex flex-col transition-transform duration-300 ease-in-out 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'
|
||||
}`}
|
||||
>
|
||||
<div className="p-8 flex-grow overflow-y-auto">
|
||||
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
||||
<div className="p-6 sm:p-8 flex-grow overflow-y-auto">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
|
||||
|
||||
<div className="flex border-b border-white/10 mb-6">
|
||||
<div className="liquid-surface grid grid-cols-2 sm:grid-cols-4 gap-1 rounded-2xl p-1 mb-7">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'general' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
||||
onClick={() => setActiveTab('general')}
|
||||
key={tab.id}
|
||||
className={`liquid-focus rounded-xl px-3 py-2 text-sm font-bold transition-all duration-200 ease-ios ${
|
||||
activeTab === tab.id
|
||||
? 'bg-cyan-400/25 text-cyan-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.28)]'
|
||||
: 'text-slate-300 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'theme' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
||||
onClick={() => setActiveTab('theme')}
|
||||
>
|
||||
Theme
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'clock' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
||||
onClick={() => setActiveTab('clock')}
|
||||
>
|
||||
Clock
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'serverWidget' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
||||
onClick={() => setActiveTab('serverWidget')}
|
||||
>
|
||||
Server Widget
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={config.title}
|
||||
onChange={handleChange}
|
||||
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
||||
<Dropdown
|
||||
name="titleSize"
|
||||
value={config.titleSize}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-300 text-sm font-semibold mb-2 block">Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
name="subtitle"
|
||||
value={config.subtitle}
|
||||
onChange={handleChange}
|
||||
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Subtitle Size</label>
|
||||
<Dropdown
|
||||
name="subtitleSize"
|
||||
value={config.subtitleSize}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
||||
<Dropdown
|
||||
name="alignment"
|
||||
value={config.alignment}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
||||
<Dropdown
|
||||
name="tileSize"
|
||||
value={config.tileSize}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
||||
<Dropdown
|
||||
name="horizontalAlignment"
|
||||
value={config.horizontalAlignment}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<GeneralTab config={config} onChange={handleConfigChange} />
|
||||
)}
|
||||
|
||||
{activeTab === 'theme' && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
||||
<Dropdown
|
||||
name="backgroundUrl"
|
||||
value={config.backgroundUrl}
|
||||
onChange={handleChange}
|
||||
options={allWallpapers.map(w => ({
|
||||
value: w.url || w.base64 || '',
|
||||
label: (
|
||||
<div className="flex items-center justify-between">
|
||||
{w.name}
|
||||
{!baseWallpapers.includes(w) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteWallpaper(w);
|
||||
}}
|
||||
className="text-red-500 hover:text-red-400 ml-4"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
<ThemeTab
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
userWallpapers={userWallpapers}
|
||||
allWallpapers={allWallpapers}
|
||||
chromeStorageAvailable={chromeStorageAvailable}
|
||||
onAddWallpaper={handleAddWallpaper}
|
||||
onAddWallpaperFile={handleAddWallpaperFile}
|
||||
onDeleteWallpaper={handleDeleteWallpaper}
|
||||
onNextWallpaper={onNextWallpaper}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
name="wallpaperBlur"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.wallpaperBlur}
|
||||
onChange={handleChange}
|
||||
className="w-48"
|
||||
/>
|
||||
<span>{config.wallpaperBlur}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
name="wallpaperBrightness"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.wallpaperBrightness}
|
||||
onChange={handleChange}
|
||||
className="w-48"
|
||||
/>
|
||||
<span>{config.wallpaperBrightness}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
name="wallpaperOpacity"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.wallpaperOpacity}
|
||||
onChange={handleChange}
|
||||
className="w-48"
|
||||
/>
|
||||
<span>{config.wallpaperOpacity}%</span>
|
||||
</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"
|
||||
value={newWallpaperName}
|
||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Image URL"
|
||||
value={newWallpaperUrl}
|
||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddWallpaper}
|
||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeTab === 'clock' && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
|
||||
<ToggleSwitch
|
||||
checked={config.clock.enabled}
|
||||
onChange={handleClockToggleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
||||
<Dropdown
|
||||
name="clock.size"
|
||||
value={config.clock.size}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
||||
<Dropdown
|
||||
name="clock.font"
|
||||
value={config.clock.font}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
|
||||
{ value: 'monospace', label: 'Monospace' },
|
||||
{ value: 'cursive', label: 'Cursive' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
||||
<Dropdown
|
||||
name="clock.format"
|
||||
value={config.clock.format}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ value: 'h:mm A', label: 'AM/PM' },
|
||||
{ value: 'HH:mm', label: '24:00' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ClockTab config={config} onChange={handleConfigChange} />
|
||||
)}
|
||||
|
||||
{activeTab === 'serverWidget' && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
|
||||
<ToggleSwitch
|
||||
checked={config.serverWidget.enabled}
|
||||
onChange={handleServerWidgetToggleChange}
|
||||
/>
|
||||
</div>
|
||||
{config.serverWidget.enabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
name="serverWidget.pingFrequency"
|
||||
min="5"
|
||||
max="60"
|
||||
value={config.serverWidget.pingFrequency}
|
||||
onChange={handleChange}
|
||||
className="w-48"
|
||||
/>
|
||||
<span>{config.serverWidget.pingFrequency}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="servers">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="flex flex-col gap-2">
|
||||
{config.serverWidget.servers.map((server: Server, index: number) => (
|
||||
<Draggable key={server.id} draggableId={server.id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{server.name}</p>
|
||||
<p className="text-sm text-slate-400">{server.address}</p>
|
||||
<ServerWidgetTab config={config} onChange={handleConfigChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 sm:p-8 border-t border-white/10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleRemoveServer(server.id)}
|
||||
className="text-red-500 hover:text-red-400"
|
||||
onClick={() => ConfigurationService.exportConfig()}
|
||||
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Server Name"
|
||||
value={newServerName}
|
||||
onChange={(e) => setNewServerName(e.target.value)}
|
||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="HTTP Address"
|
||||
value={newServerAddress}
|
||||
onChange={(e) => setNewServerAddress(e.target.value)}
|
||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddServer}
|
||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
|
||||
>
|
||||
Add
|
||||
Import
|
||||
</button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={handleImportConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 border-t border-white/10">
|
||||
<div className="flex justify-end gap-4">
|
||||
<button onClick={() => onSave(config)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
isSaving.current = true;
|
||||
onSave(config);
|
||||
}}
|
||||
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5"
|
||||
>
|
||||
Save & Close
|
||||
</button>
|
||||
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,17 +2,16 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface DropdownProps {
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (e: { target: { name: string; value: string } }) => void;
|
||||
value: string | string[];
|
||||
onChange: (e: { target: { name: string; value: string | string[] } }) => void;
|
||||
name?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...rest }) => {
|
||||
const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, multiple = false, ...rest }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOptionLabel = options.find(option => option.value === value)?.label || '';
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
@@ -26,21 +25,53 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
}, []);
|
||||
|
||||
const handleOptionClick = (optionValue: string) => {
|
||||
let newValue: string | string[];
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
if (currentValues.includes(optionValue)) {
|
||||
newValue = currentValues.filter((v) => v !== optionValue);
|
||||
} else {
|
||||
newValue = [...currentValues, optionValue];
|
||||
}
|
||||
} else {
|
||||
newValue = optionValue;
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name: name || '',
|
||||
value: optionValue,
|
||||
value: newValue,
|
||||
},
|
||||
};
|
||||
onChange(syntheticEvent);
|
||||
setIsOpen(false);
|
||||
onChange(syntheticEvent as any);
|
||||
};
|
||||
|
||||
const selectedOptionLabel = (() => {
|
||||
if (multiple) {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (value.length === 1) {
|
||||
return options.find((o) => o.value === value[0])?.label || '';
|
||||
}
|
||||
return `${value.length} selected`;
|
||||
}
|
||||
return 'Select...';
|
||||
}
|
||||
return options.find((option) => option.value === value)?.label || 'Select...';
|
||||
})();
|
||||
|
||||
const isSelected = (optionValue: string) => {
|
||||
if (multiple && Array.isArray(value)) {
|
||||
return value.includes(optionValue);
|
||||
}
|
||||
return optionValue === value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-black/5 backdrop-blur-md border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-cyan-400 w-40 h-10 flex justify-between items-center transition-all duration-200 hover:bg-white/5"
|
||||
className="liquid-surface liquid-focus rounded-xl px-3 text-white text-sm w-44 h-11 flex justify-between items-center transition-all duration-200 ease-ios hover:border-white/30"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
@@ -48,7 +79,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
>
|
||||
<span className="truncate">{selectedOptionLabel}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform duration-300 ease-in-out ${isOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
className={`w-5 h-5 transition-transform duration-200 ease-ios ${isOpen ? 'rotate-180' : 'rotate-0'}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
@@ -64,20 +95,21 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
|
||||
{isOpen && (
|
||||
<ul
|
||||
className="absolute z-10 mt-1 w-full bg-black/70 backdrop-blur-xl border border-white/20 rounded-lg shadow-2xl overflow-hidden animate-in slide-in-from-top-2 fade-in duration-200"
|
||||
className="liquid-panel liquid-dropdown-list absolute z-10 mt-2 w-full rounded-xl overflow-hidden"
|
||||
role="listbox"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<li
|
||||
key={option.value}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center
|
||||
${option.value === value
|
||||
? 'bg-cyan-500/20 text-cyan-300'
|
||||
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
|
||||
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-ios flex items-center
|
||||
${
|
||||
isSelected(option.value)
|
||||
? 'bg-cyan-400/20 text-cyan-200'
|
||||
: 'hover:bg-white/20 hover:text-white'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
aria-selected={isSelected(option.value)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</li>
|
||||
@@ -85,8 +117,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, ...
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Hidden input to mimic native select behavior for forms */}
|
||||
{name && <input type="hidden" name={name} value={value} />}
|
||||
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import { getWebsiteIcon } from './utils/iconService';
|
||||
import { Category, Website } from '../types';
|
||||
import IconPicker from './IconPicker';
|
||||
import { icons } from 'lucide-react';
|
||||
|
||||
interface EditModalProps {
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
onSave: (categories: Category[]) => void;
|
||||
}
|
||||
|
||||
const EditModal: React.FC<EditModalProps> = ({ categories, onClose, onSave }) => {
|
||||
const [localCategories, setLocalCategories] = useState(categories);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id || null);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [newWebsite, setNewWebsite] = useState({ name: '', url: '', icon: '' });
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleOnDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
if (source.droppableId === destination.droppableId) {
|
||||
const category = localCategories.find(cat => cat.id === source.droppableId);
|
||||
if (category) {
|
||||
const items = Array.from(category.websites);
|
||||
const [reorderedItem] = items.splice(source.index, 1);
|
||||
items.splice(destination.index, 0, reorderedItem);
|
||||
const updatedCategories = localCategories.map(cat =>
|
||||
cat.id === category.id ? { ...cat, websites: items } : cat
|
||||
);
|
||||
setLocalCategories(updatedCategories);
|
||||
}
|
||||
} else {
|
||||
const sourceCategory = localCategories.find(cat => cat.id === source.droppableId);
|
||||
const destCategory = localCategories.find(cat => cat.id === destination.droppableId);
|
||||
if (sourceCategory && destCategory) {
|
||||
const sourceItems = Array.from(sourceCategory.websites);
|
||||
const [movedItem] = sourceItems.splice(source.index, 1);
|
||||
const destItems = Array.from(destCategory.websites);
|
||||
destItems.splice(destination.index, 0, { ...movedItem, categoryId: destCategory.id });
|
||||
|
||||
const updatedCategories = localCategories.map(cat => {
|
||||
if (cat.id === sourceCategory.id) return { ...cat, websites: sourceItems };
|
||||
if (cat.id === destCategory.id) return { ...cat, websites: destItems };
|
||||
return cat;
|
||||
});
|
||||
setLocalCategories(updatedCategories);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCategory = () => {
|
||||
if (newCategoryName.trim() === '') return;
|
||||
const newCategory: Category = {
|
||||
id: Date.now().toString(),
|
||||
name: newCategoryName,
|
||||
websites: [],
|
||||
};
|
||||
setLocalCategories([...localCategories, newCategory]);
|
||||
setNewCategoryName('');
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (id: string) => {
|
||||
const updatedCategories = localCategories.filter(cat => cat.id !== id);
|
||||
setLocalCategories(updatedCategories);
|
||||
if (selectedCategoryId === id) {
|
||||
setSelectedCategoryId(updatedCategories[0]?.id || null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddWebsite = async () => {
|
||||
if (!selectedCategoryId || !newWebsite.name || !newWebsite.url) return;
|
||||
|
||||
let icon = newWebsite.icon;
|
||||
if (!icon || !Object.keys(icons).includes(icon)) {
|
||||
icon = await getWebsiteIcon(newWebsite.url);
|
||||
}
|
||||
|
||||
const newWebsiteData: Website = {
|
||||
id: Date.now().toString(),
|
||||
name: newWebsite.name,
|
||||
url: newWebsite.url,
|
||||
icon,
|
||||
categoryId: selectedCategoryId,
|
||||
};
|
||||
|
||||
const updatedCategories = localCategories.map(cat => {
|
||||
if (cat.id === selectedCategoryId) {
|
||||
return { ...cat, websites: [...cat.websites, newWebsiteData] };
|
||||
}
|
||||
return cat;
|
||||
});
|
||||
|
||||
setLocalCategories(updatedCategories);
|
||||
setNewWebsite({ name: '', url: '', icon: '' });
|
||||
};
|
||||
|
||||
const handleRemoveWebsite = (categoryId: string, websiteId: string) => {
|
||||
const updatedCategories = localCategories.map(cat => {
|
||||
if (cat.id === categoryId) {
|
||||
return { ...cat, websites: cat.websites.filter(web => web.id !== websiteId) };
|
||||
}
|
||||
return cat;
|
||||
});
|
||||
setLocalCategories(updatedCategories);
|
||||
};
|
||||
|
||||
const selectedCategory = localCategories.find(cat => cat.id === selectedCategoryId);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div ref={modalRef} className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-4xl text-white">
|
||||
<h2 className="text-3xl font-bold mb-6">Edit Bookmarks</h2>
|
||||
<div className="grid grid-cols-3 gap-8">
|
||||
<div className="col-span-1">
|
||||
<h3 className="text-xl font-semibold mb-4">Categories</h3>
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
{localCategories.map(category => (
|
||||
<div
|
||||
key={category.id}
|
||||
className={`flex justify-between items-center p-3 rounded-lg cursor-pointer ${selectedCategoryId === category.id ? 'bg-cyan-500/50' : 'bg-white/10'}`}
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
>
|
||||
<span>{category.name}</span>
|
||||
<button onClick={() => handleRemoveCategory(category.id)} className="text-red-500 hover:text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New Category"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<button onClick={handleAddCategory} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<h3 className="text-xl font-semibold mb-4">Websites</h3>
|
||||
{selectedCategory && (
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<Droppable droppableId={selectedCategory.id}>
|
||||
{(provided) => (
|
||||
<ul {...provided.droppableProps} ref={provided.innerRef} className="mb-8">
|
||||
{selectedCategory.websites.map((website, index) => (
|
||||
<Draggable key={website.id} draggableId={website.id} index={index}>
|
||||
{(provided) => (
|
||||
<li
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="flex items-center justify-between bg-white/10 p-3 rounded-lg mb-3"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{Object.keys(icons).includes(website.icon) ? (
|
||||
React.createElement(icons[website.icon as keyof typeof icons], { className: "h-8 w-8 mr-4" })
|
||||
) : (
|
||||
<img src={website.icon} alt={website.name} className="h-8 w-8 mr-4" />
|
||||
)}
|
||||
<span>{website.name}</span>
|
||||
</div>
|
||||
<button onClick={() => handleRemoveWebsite(selectedCategory.id, website.id)} className="text-red-500 hover:text-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</ul>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Add New Bookmark</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
value={newWebsite.name}
|
||||
onChange={(e) => setNewWebsite({ ...newWebsite, name: e.target.value })}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={newWebsite.url}
|
||||
onChange={(e) => setNewWebsite({ ...newWebsite, url: e.target.value })}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Icon URL or name"
|
||||
value={newWebsite.icon}
|
||||
onChange={(e) => setNewWebsite({ ...newWebsite, icon: e.target.value })}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
||||
/>
|
||||
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
||||
{showIconPicker ? 'Close' : 'Select Icon'}
|
||||
</button>
|
||||
</div>
|
||||
{showIconPicker && (
|
||||
<IconPicker
|
||||
onSelect={(iconName) => {
|
||||
setNewWebsite({ ...newWebsite, icon: iconName });
|
||||
setShowIconPicker(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button onClick={handleAddWebsite} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-3 px-4 rounded-lg">
|
||||
Add Bookmark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-4 mt-8">
|
||||
<button onClick={() => onSave(localCategories)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
@@ -1,48 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { icons } from 'lucide-react';
|
||||
|
||||
interface IconPickerProps {
|
||||
onSelect: (iconName: string) => void;
|
||||
}
|
||||
|
||||
const IconPicker: React.FC<IconPickerProps> = ({ onSelect }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!search) {
|
||||
return Object.keys(icons).slice(0, 50);
|
||||
}
|
||||
return Object.keys(icons).filter(name =>
|
||||
name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 p-4 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for an icon..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full p-2 mb-4 bg-gray-700 rounded text-white"
|
||||
/>
|
||||
<div className="grid grid-cols-6 gap-4 max-h-60 overflow-y-auto">
|
||||
{filteredIcons.map(iconName => {
|
||||
const LucideIcon = icons[iconName as keyof typeof icons];
|
||||
return (
|
||||
<div
|
||||
key={iconName}
|
||||
onClick={() => onSelect(iconName)}
|
||||
className="cursor-pointer flex flex-col items-center justify-center p-2 rounded-lg hover:bg-gray-700"
|
||||
>
|
||||
<LucideIcon color="white" size={24} />
|
||||
<span className="text-xs text-white mt-1">{iconName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconPicker;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Server } from '../types';
|
||||
import ping from './utils/jsping.js';
|
||||
|
||||
@@ -12,53 +12,70 @@ interface ServerWidgetProps {
|
||||
};
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-400 text-green-400';
|
||||
case 'offline':
|
||||
return 'bg-red-400 text-red-400';
|
||||
default:
|
||||
return 'bg-slate-400 text-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||
const serversRef = useRef(config.serverWidget.servers);
|
||||
serversRef.current = config.serverWidget.servers;
|
||||
|
||||
const serversSignature = config.serverWidget.servers
|
||||
.map(s => `${s.id}:${s.address}`)
|
||||
.join('|');
|
||||
const serverCount = config.serverWidget.servers.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.serverWidget.enabled) return;
|
||||
|
||||
const pingServers = () => {
|
||||
config.serverWidget.servers.forEach((server) => {
|
||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'pending' }));
|
||||
const pending: Record<string, string> = {};
|
||||
for (const s of serversRef.current) pending[s.id] = 'pending';
|
||||
setServerStatus(prev => ({ ...prev, ...pending }));
|
||||
|
||||
serversRef.current.forEach((server) => {
|
||||
ping(server.address)
|
||||
.then(() => {
|
||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'online' }));
|
||||
setServerStatus(prev =>
|
||||
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'offline' }));
|
||||
setServerStatus(prev =>
|
||||
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (config.serverWidget.enabled) {
|
||||
pingServers();
|
||||
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [config.serverWidget.enabled, config.serverWidget.servers, config.serverWidget.pingFrequency]);
|
||||
}, [
|
||||
config.serverWidget.enabled,
|
||||
config.serverWidget.pingFrequency,
|
||||
serversSignature,
|
||||
serverCount,
|
||||
]);
|
||||
|
||||
if (!config.serverWidget.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-500';
|
||||
case 'offline':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-auto max-w-full">
|
||||
<div className="flex items-center gap-4 bg-black/25 backdrop-blur-md border border-white/20 px-4 py-2 shadow-lg"
|
||||
style={{ borderBottomLeftRadius: '0', borderBottomRightRadius: '0', borderTopLeftRadius: '16px', borderTopRightRadius: '15px', borderBottomWidth: '0' }}
|
||||
>
|
||||
<div className="fixed bottom-3 left-1/2 z-20 w-auto max-w-[calc(100%-1.5rem)] -translate-x-1/2">
|
||||
<div className="liquid-surface flex items-center gap-4 rounded-full px-4 py-2">
|
||||
{config.serverWidget.servers.map((server) => (
|
||||
<div key={server.id} className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div>
|
||||
<div className={`liquid-status-dot w-2.5 h-2.5 rounded-full ${getStatusColor(serverStatus[server.id])}`}></div>
|
||||
<span className="text-slate-100 text-sm font-medium">
|
||||
{server.name}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
@@ -11,14 +11,20 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-14 h-8 flex items-center rounded-full p-1 cursor-pointer transition-colors duration-300 ${checked ? 'bg-cyan-500' : 'bg-gray-600'}`}
|
||||
<button
|
||||
type="button"
|
||||
className={`liquid-focus relative w-14 h-8 flex items-center rounded-full p-1 cursor-pointer border transition-all duration-200 ease-ios ${
|
||||
checked
|
||||
? 'bg-cyan-400/70 border-cyan-200/60 shadow-[0_0_24px_rgba(34,211,238,0.22)]'
|
||||
: 'bg-white/10 border-white/20'
|
||||
}`}
|
||||
onClick={handleToggle}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<div
|
||||
className={`bg-white w-6 h-6 rounded-full shadow-md transform transition-transform duration-300 ${checked ? 'translate-x-6' : 'translate-x-0'}`}
|
||||
className={`bg-white w-6 h-6 rounded-full shadow-lg transform transition-transform duration-200 ease-spring ${checked ? 'translate-x-6' : 'translate-x-0'}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
149
components/Wallpaper.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { baseWallpapers } from './utils/baseWallpapers';
|
||||
import { Wallpaper as WallpaperType } from '../types';
|
||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||
|
||||
interface WallpaperProps {
|
||||
wallpaperNames: string[];
|
||||
blur: number;
|
||||
brightness: number;
|
||||
opacity: number;
|
||||
wallpaperFrequency: string;
|
||||
wallpaperVersion: number;
|
||||
}
|
||||
|
||||
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
|
||||
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
|
||||
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const parseFrequencyToMs = (freq: string): number => {
|
||||
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const match = freq.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
||||
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
||||
};
|
||||
|
||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||
|
||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||
|
||||
let resolved: string | undefined;
|
||||
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
||||
if (foundInBase) {
|
||||
resolved = foundInBase.url || foundInBase.base64;
|
||||
} else {
|
||||
try {
|
||||
const storedUserWallpapers: WallpaperType[] =
|
||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||
if (foundInUser) {
|
||||
try {
|
||||
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
||||
if (wallpaperData && wallpaperData.startsWith('http')) {
|
||||
resolved = wallpaperData;
|
||||
} else {
|
||||
resolved = wallpaperData || undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting wallpaper from chrome storage', error);
|
||||
resolved = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading userWallpapers from localStorage', error);
|
||||
resolved = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
wallpaperUrlCache.set(name, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||
const resolvedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateWallpaper = async () => {
|
||||
if (wallpaperNames.length === 0) {
|
||||
setImageUrl(undefined);
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||
const lastChange = wallpaperState.lastWallpaperChange
|
||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||
: 0;
|
||||
const now = Date.now();
|
||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
||||
|
||||
let storedIndex =
|
||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
|
||||
|
||||
const shouldRotate = now - lastChange >= freqMs;
|
||||
let resolvedIndex = shouldRotate
|
||||
? (storedIndex + 1) % wallpaperNames.length
|
||||
: storedIndex;
|
||||
|
||||
const tried = new Set<number>();
|
||||
let resolvedUrl: string | undefined;
|
||||
|
||||
for (let i = 0; i < wallpaperNames.length; i++) {
|
||||
if (tried.has(resolvedIndex)) break;
|
||||
tried.add(resolvedIndex);
|
||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||
if (url) {
|
||||
resolvedUrl = url;
|
||||
break;
|
||||
}
|
||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||
}
|
||||
|
||||
const nextLastChange = shouldRotate
|
||||
? new Date().toISOString()
|
||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: nextLastChange,
|
||||
currentIndex: resolvedIndex,
|
||||
}),
|
||||
);
|
||||
|
||||
resolvedRef.current = true;
|
||||
setImageUrl(resolvedUrl);
|
||||
};
|
||||
updateWallpaper();
|
||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||
|
||||
if (!imageUrl) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="wallpaper-layer wallpaper-transition"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||
opacity: opacity / 100,
|
||||
}}
|
||||
aria-label="Wallpaper background"
|
||||
/>
|
||||
<div className="wallpaper-luminance" aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Wallpaper;
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Website } from '../types';
|
||||
import IconPicker from './IconPicker';
|
||||
import { getWebsiteIcon } from './utils/iconService';
|
||||
import { icons } from 'lucide-react';
|
||||
|
||||
interface WebsiteEditModalProps {
|
||||
website?: Website;
|
||||
@@ -12,21 +10,91 @@ interface WebsiteEditModalProps {
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
interface IconMetadata {
|
||||
name: string;
|
||||
base: string;
|
||||
aliases: string[];
|
||||
categories: string[];
|
||||
update: {
|
||||
timestamp: string;
|
||||
author: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
colors: any;
|
||||
}
|
||||
|
||||
let iconMetadataCache: IconMetadata[] | null = null;
|
||||
|
||||
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
|
||||
const [name, setName] = useState(website ? website.name : '');
|
||||
const [url, setUrl] = useState(website ? website.url : '');
|
||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
||||
const [iconQuery, setIconQuery] = useState('');
|
||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>(() => iconMetadataCache ?? []);
|
||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
const ensureIconMetadata = () => {
|
||||
if (iconMetadataCache) {
|
||||
setIconMetadata(iconMetadataCache);
|
||||
return;
|
||||
}
|
||||
if (iconsFetched) return;
|
||||
setIconsFetched(true);
|
||||
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({
|
||||
name,
|
||||
...(details as object),
|
||||
})) as IconMetadata[];
|
||||
iconMetadataCache = iconsArray;
|
||||
setIconMetadata(iconsArray);
|
||||
})
|
||||
.catch(err => console.error('Failed to load icon metadata', err));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (iconQuery && Array.isArray(iconMetadata) && iconMetadata.length > 0) {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
const lowerCaseQuery = iconQuery.toLowerCase();
|
||||
const filtered: IconMetadata[] = [];
|
||||
for (const ic of iconMetadata) {
|
||||
if (ic.name.toLowerCase().includes(lowerCaseQuery)) {
|
||||
filtered.push(ic);
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
if (ic.colors) {
|
||||
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
|
||||
for (const color of colors) {
|
||||
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
|
||||
filtered.push({ ...ic, name: color });
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
}
|
||||
if (filtered.length >= 50) break;
|
||||
}
|
||||
}
|
||||
setFilteredIcons(filtered);
|
||||
}, 150);
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current);
|
||||
};
|
||||
} else {
|
||||
setFilteredIcons([]);
|
||||
}
|
||||
}, [iconQuery, iconMetadata]);
|
||||
|
||||
const fetchIcon = async () => {
|
||||
if (url) {
|
||||
const fetchedIcon = await getWebsiteIcon(url);
|
||||
setIcon(fetchedIcon);
|
||||
}
|
||||
};
|
||||
fetchIcon();
|
||||
}, [url]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ id: website?.id, name, url, icon });
|
||||
@@ -38,18 +106,22 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
}
|
||||
};
|
||||
|
||||
const LucideIcon = icons[icon as keyof typeof icons];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
||||
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
|
||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
{LucideIcon ? (
|
||||
<LucideIcon className="h-24 w-24 text-white" />
|
||||
) : (
|
||||
{icon ? (
|
||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||
) : (
|
||||
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
@@ -57,49 +129,69 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<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)}
|
||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
||||
onChange={(e) => {
|
||||
setIcon(e.target.value);
|
||||
setIconQuery(e.target.value);
|
||||
}}
|
||||
onFocus={ensureIconMetadata}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
||||
{showIconPicker ? 'Close' : 'Select Icon'}
|
||||
{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>
|
||||
{showIconPicker && (
|
||||
<IconPicker
|
||||
onSelect={(iconName) => {
|
||||
setIcon(iconName);
|
||||
setShowIconPicker(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<div>
|
||||
{edit && (
|
||||
<button onClick={onDelete} className="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<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-4">
|
||||
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<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="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
74
components/WebsiteTile.tsx
Executable file → Normal file
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { Website } from '../types';
|
||||
import { icons, ArrowLeft, ArrowRight, Pencil } from 'lucide-react';
|
||||
|
||||
interface WebsiteTileProps {
|
||||
website: Website;
|
||||
@@ -23,21 +22,35 @@ const getTileSizeClass = (size: string | undefined) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = (size: string | undefined) => {
|
||||
|
||||
const getIconPixelSize = (size: string | undefined): number => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 8;
|
||||
return 34;
|
||||
case 'medium':
|
||||
return 10;
|
||||
return 42;
|
||||
case 'large':
|
||||
return 12;
|
||||
return 48;
|
||||
default:
|
||||
return 10;
|
||||
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 LucideIcon = icons[website.icon as keyof typeof icons];
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
@@ -46,56 +59,53 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate loading time (dev purpose)
|
||||
// e.preventDefault();
|
||||
// setTimeout(() => {
|
||||
// setIsLoading(false);
|
||||
// }, 3500); // Small delay to show spinner before navigation
|
||||
};
|
||||
|
||||
const iconSizeClass = `w-${getIconSize(tileSize)} h-${getIconSize(tileSize)}`;
|
||||
const iconSizeLoadingClass = `w-${getIconSize(tileSize) - 4} h-${getIconSize(tileSize) - 4}`;
|
||||
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
||||
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
||||
|
||||
return (
|
||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-300 ease-in-out`}>
|
||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios ${isEditing ? 'mb-4' : ''}`}>
|
||||
<a
|
||||
href={isEditing ? undefined : website.url}
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-300 ease-in-out hover:scale-105 hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
|
||||
className={`liquid-surface liquid-tile liquid-focus group flex flex-col items-center justify-center w-full h-full p-4 ${isEditing ? 'pb-6' : ''}`}
|
||||
aria-label={isEditing ? `${website.name} edit controls` : `Open ${website.name}`}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center mb-6">
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center mb-6">
|
||||
<svg className="animate-spin h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center transition-all duration-300 ease-in ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
|
||||
<div className={`transition-all duration-300 ease-in ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||
{LucideIcon ? (
|
||||
<LucideIcon className={`text-white ${isLoading ? iconSizeLoadingClass : iconSizeClass}`} />
|
||||
) : (
|
||||
<img src={website.icon} alt={`${website.name} icon`} className="object-contain" />
|
||||
)}
|
||||
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
|
||||
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" />
|
||||
</div>
|
||||
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-300 ease-in ${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}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{isEditing && (
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
|
||||
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><ArrowLeft size={16} /></button>
|
||||
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><Pencil size={16} /></button>
|
||||
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><ArrowRight size={16} /></button>
|
||||
<div className="liquid-surface liquid-edit-toolbar">
|
||||
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||
</svg></button>
|
||||
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||
</svg></button>
|
||||
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||
</svg></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsiteTile;
|
||||
export default memo(WebsiteTile);
|
||||
|
||||
69
components/configuration/ClockTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Dropdown from '../Dropdown';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config } from '../../types';
|
||||
|
||||
interface ClockTabProps {
|
||||
config: Config;
|
||||
onChange: (updates: Partial<Config>) => void;
|
||||
}
|
||||
|
||||
const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
||||
const updateClock = (updates: Partial<Config['clock']>) => {
|
||||
onChange({ clock: { ...config.clock, ...updates } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">Enable Clock</label>
|
||||
<ToggleSwitch
|
||||
checked={config.clock.enabled}
|
||||
onChange={(checked) => updateClock({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
||||
<Dropdown
|
||||
name="clock.size"
|
||||
value={config.clock.size}
|
||||
onChange={(e) => updateClock({ size: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
||||
<Dropdown
|
||||
name="clock.font"
|
||||
value={config.clock.font}
|
||||
onChange={(e) => updateClock({ font: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
|
||||
{ value: 'monospace', label: 'Monospace' },
|
||||
{ value: 'cursive', label: 'Cursive' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
||||
<Dropdown
|
||||
name="clock.format"
|
||||
value={config.clock.format}
|
||||
onChange={(e) => updateClock({ format: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'h:mm A', label: 'AM/PM' },
|
||||
{ value: 'HH:mm', label: '24:00' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClockTab;
|
||||
79
components/configuration/GeneralTab.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import Dropdown from '../Dropdown';
|
||||
import { Config } from '../../types';
|
||||
|
||||
interface GeneralTabProps {
|
||||
config: Config;
|
||||
onChange: (updates: Partial<Config>) => void;
|
||||
}
|
||||
|
||||
const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.title}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
className="liquid-input p-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
||||
<Dropdown
|
||||
name="titleSize"
|
||||
value={config.titleSize}
|
||||
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'tiny', label: 'Tiny' },
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
||||
<Dropdown
|
||||
name="alignment"
|
||||
value={config.alignment}
|
||||
onChange={(e) => onChange({ alignment: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'bottom', label: 'Bottom' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
||||
<Dropdown
|
||||
name="tileSize"
|
||||
value={config.tileSize || 'medium'}
|
||||
onChange={(e) => onChange({ tileSize: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'small', label: 'Small' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'large', label: 'Large' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
||||
<Dropdown
|
||||
name="horizontalAlignment"
|
||||
value={config.horizontalAlignment}
|
||||
onChange={(e) => onChange({ horizontalAlignment: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'middle', label: 'Middle' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralTab;
|
||||
159
components/configuration/ServerWidgetTab.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Config, Server } from '../../types';
|
||||
|
||||
interface ServerWidgetTabProps {
|
||||
config: Config;
|
||||
onChange: (updates: Partial<Config>) => void;
|
||||
}
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerAddress, setNewServerAddress] = useState('');
|
||||
|
||||
const updateServerWidget = (updates: Partial<Config['serverWidget']>) => {
|
||||
onChange({ serverWidget: { ...config.serverWidget, ...updates } });
|
||||
};
|
||||
|
||||
const handleAddServer = () => {
|
||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||
const newServer: Server = {
|
||||
id: Date.now().toString(),
|
||||
name: newServerName,
|
||||
address: newServerAddress,
|
||||
};
|
||||
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
|
||||
setNewServerName('');
|
||||
setNewServerAddress('');
|
||||
};
|
||||
|
||||
const handleRemoveServer = (id: string) => {
|
||||
updateServerWidget({
|
||||
servers: config.serverWidget.servers.filter((s) => s.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = (result: any) => {
|
||||
if (!result.destination) return;
|
||||
const items = Array.from(config.serverWidget.servers);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
updateServerWidget({ servers: items });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">Enable Server Widget</label>
|
||||
<ToggleSwitch
|
||||
checked={config.serverWidget.enabled}
|
||||
onChange={(checked) => updateServerWidget({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
{config.serverWidget.enabled && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="60"
|
||||
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>
|
||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="servers">
|
||||
{(provided) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{config.serverWidget.servers.map((server: Server, index: number) => (
|
||||
<Draggable key={server.id} draggableId={server.id} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="liquid-surface flex items-center justify-between rounded-xl p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{server.name}</p>
|
||||
<p className="text-sm text-slate-400">{server.address}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveServer(server.id)}
|
||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||
aria-label={`Remove ${server.name}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-trash"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<div className="flex flex-col gap-2 mt-3 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Server Name"
|
||||
value={newServerName}
|
||||
onChange={(e) => setNewServerName(e.target.value)}
|
||||
className="liquid-input p-2.5"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="HTTP Address"
|
||||
value={newServerAddress}
|
||||
onChange={(e) => setNewServerAddress(e.target.value)}
|
||||
className="liquid-input p-2.5"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddServer}
|
||||
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerWidgetTab;
|
||||
284
components/configuration/ThemeTab.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import Dropdown from '../Dropdown';
|
||||
import { Config, Wallpaper } from '../../types';
|
||||
|
||||
interface ThemeTabProps {
|
||||
config: Config;
|
||||
onChange: (updates: Partial<Config>) => void;
|
||||
userWallpapers: Wallpaper[];
|
||||
allWallpapers: Wallpaper[];
|
||||
chromeStorageAvailable: boolean;
|
||||
onAddWallpaper: (name: string, url: string) => Promise<void>;
|
||||
onAddWallpaperFile: (file: File) => Promise<void>;
|
||||
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
|
||||
onNextWallpaper: () => void;
|
||||
}
|
||||
|
||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||
|
||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||
return { '--range-progress': `${progress}%` };
|
||||
};
|
||||
|
||||
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||
|
||||
const clampWallpaperFrequencyHours = (hours: number): number =>
|
||||
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
|
||||
|
||||
const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||
const value = Number(match[1]);
|
||||
const hours = match[2] === 'd' ? value * 24 : value;
|
||||
return clampWallpaperFrequencyHours(hours);
|
||||
};
|
||||
|
||||
const formatWallpaperFrequency = (hours: number): string =>
|
||||
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||
|
||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
userWallpapers,
|
||||
allWallpapers,
|
||||
chromeStorageAvailable,
|
||||
onAddWallpaper,
|
||||
onAddWallpaperFile,
|
||||
onDeleteWallpaper,
|
||||
onNextWallpaper,
|
||||
}) => {
|
||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||
|
||||
const handleAddWallpaper = async () => {
|
||||
if (newWallpaperUrl.trim() === '') return;
|
||||
try {
|
||||
await onAddWallpaper(newWallpaperName, newWallpaperUrl);
|
||||
setNewWallpaperName('');
|
||||
setNewWallpaperUrl('');
|
||||
} catch (error) {
|
||||
alert('Error adding wallpaper. Please check the URL and try again.');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await onAddWallpaperFile(file);
|
||||
} catch (error: any) {
|
||||
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
||||
console.error(error);
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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">Background</label>
|
||||
<Dropdown
|
||||
name="currentWallpapers"
|
||||
value={config.currentWallpapers}
|
||||
onChange={(e) => onChange({ currentWallpapers: e.target.value as string[] })}
|
||||
multiple
|
||||
options={allWallpapers.map((w) => ({ value: w.name, label: w.name }))}
|
||||
/>
|
||||
</div>
|
||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||
step="1"
|
||||
value={wallpaperFrequencyHours}
|
||||
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(
|
||||
wallpaperFrequencyHours,
|
||||
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||
)}
|
||||
/>
|
||||
<span className="w-20 text-right text-sm text-slate-200">
|
||||
{formatWallpaperFrequency(wallpaperFrequencyHours)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
value={config.wallpaperBlur}
|
||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.wallpaperBrightness}
|
||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.wallpaperOpacity}
|
||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||
className="liquid-range"
|
||||
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
||||
/>
|
||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{chromeStorageAvailable && (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{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}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
className="bi bi-trash"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</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
|
||||
type="text"
|
||||
placeholder="Image URL"
|
||||
value={newWallpaperUrl}
|
||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
||||
className="liquid-input p-2.5"
|
||||
/>
|
||||
<button
|
||||
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 className="flex justify-center pt-2">
|
||||
<button
|
||||
onClick={onNextWallpaper}
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 1 .5-.5h5.379L8.646 5.354a.5.5 0 1 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708L10.379 8H5a.5.5 0 0 1-.5-.5z" />
|
||||
</svg>
|
||||
Next Wallpaper
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeTab;
|
||||
91
components/layout/CategoryGroup.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { memo } from 'react';
|
||||
import WebsiteTile from '../WebsiteTile';
|
||||
import { Category, Website } from '../../types';
|
||||
|
||||
interface CategoryGroupProps {
|
||||
category: Category;
|
||||
isEditing: boolean;
|
||||
setEditingCategory: (category: Category) => void;
|
||||
setIsCategoryModalOpen: (isOpen: boolean) => void;
|
||||
setAddingWebsite: (category: Category) => void;
|
||||
setEditingWebsite: (website: Website) => void;
|
||||
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
|
||||
getHorizontalAlignmentClass: (alignment: string) => string;
|
||||
horizontalAlignment: string;
|
||||
tileSize?: string;
|
||||
}
|
||||
|
||||
const getAddTileSizeClass = (size: string | undefined) => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'w-28 h-28';
|
||||
case 'medium':
|
||||
return 'w-32 h-32';
|
||||
case 'large':
|
||||
return 'w-36 h-36';
|
||||
default:
|
||||
return 'w-32 h-32';
|
||||
}
|
||||
};
|
||||
|
||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||
category,
|
||||
isEditing,
|
||||
setEditingCategory,
|
||||
setIsCategoryModalOpen,
|
||||
setAddingWebsite,
|
||||
setEditingWebsite,
|
||||
handleMoveWebsite,
|
||||
getHorizontalAlignmentClass,
|
||||
horizontalAlignment,
|
||||
tileSize,
|
||||
}) => {
|
||||
return (
|
||||
<div key={category.id} className="w-full">
|
||||
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCategory(category);
|
||||
setIsCategoryModalOpen(true);
|
||||
}}
|
||||
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||
aria-label={`Edit ${category.name} category`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||
{category.websites.map((website) => (
|
||||
<WebsiteTile
|
||||
key={website.id}
|
||||
website={website}
|
||||
isEditing={isEditing}
|
||||
onEdit={setEditingWebsite}
|
||||
onMove={handleMoveWebsite}
|
||||
tileSize={tileSize}
|
||||
/>
|
||||
))}
|
||||
{isEditing && (
|
||||
<button
|
||||
onClick={() => setAddingWebsite(category)}
|
||||
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getAddTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||
aria-label={`Add website to ${category.name}`}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
<span className="text-sm font-bold">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CategoryGroup);
|
||||
24
components/layout/ConfigurationButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
|
||||
|
||||
interface ConfigurationButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="liquid-surface liquid-control liquid-focus rounded-2xl p-3"
|
||||
aria-label="Open configuration"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09c.7 0 1.31-.4 1.51-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.51 1.31.61 1.82.33.51-.28 1-.81 1-1.51V3a2 2 0 1 1 4 0v.09c0 .7.49 1.23 1 1.51.51.28 1.31.18 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82c.2.6.81 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.7 0-1.31.4-1.51 1z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigurationButton;
|
||||
25
components/layout/EditButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
|
||||
interface EditButtonProps {
|
||||
isEditing: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-20">
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||
</svg>
|
||||
{isEditing ? 'Done' : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditButton;
|
||||
78
components/layout/Header.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import Clock from '../Clock';
|
||||
import { Config } from '../../types';
|
||||
|
||||
interface HeaderProps {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const getClockSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-3xl';
|
||||
case 'small':
|
||||
return 'text-4xl';
|
||||
case 'medium':
|
||||
return 'text-5xl';
|
||||
case 'large':
|
||||
return 'text-6xl';
|
||||
default:
|
||||
return 'text-5xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitleSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-4xl';
|
||||
case 'small':
|
||||
return 'text-5xl';
|
||||
case 'medium':
|
||||
return 'text-6xl';
|
||||
case 'large':
|
||||
return 'text-7xl';
|
||||
default:
|
||||
return 'text-6xl';
|
||||
}
|
||||
};
|
||||
|
||||
const getSubtitleSizeClass = (size: string) => {
|
||||
switch (size) {
|
||||
case 'tiny':
|
||||
return 'text-lg';
|
||||
case 'small':
|
||||
return 'text-xl';
|
||||
case 'medium':
|
||||
return 'text-2xl';
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
|
||||
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||
return (
|
||||
<>
|
||||
{config.clock.enabled && (
|
||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||
{config.title && (
|
||||
<div className="text-center">
|
||||
<h1
|
||||
className={`liquid-title-text ${getTitleSizeClass(config.titleSize)} font-extrabold text-white mb-2 mt-3`}
|
||||
>
|
||||
{config.title}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
205
components/services/ConfigurationService.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Config, Wallpaper } from '../../types';
|
||||
import {
|
||||
addWallpaperToChromeStorageLocal,
|
||||
removeWallpaperFromChromeStorageLocal,
|
||||
} from '../utils/StorageLocalManager';
|
||||
|
||||
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||
|
||||
export const DEFAULT_CONFIG: Config = {
|
||||
title: 'Vision Start',
|
||||
currentWallpapers: ['Beach'],
|
||||
wallpaperFrequency: '1d',
|
||||
wallpaperBlur: 0,
|
||||
wallpaperBrightness: 108,
|
||||
wallpaperOpacity: 96,
|
||||
titleSize: 'medium',
|
||||
alignment: 'middle',
|
||||
horizontalAlignment: 'middle',
|
||||
tileSize: 'medium',
|
||||
clock: {
|
||||
enabled: true,
|
||||
size: 'medium',
|
||||
font: 'Helvetica',
|
||||
format: 'h:mm A',
|
||||
},
|
||||
serverWidget: {
|
||||
enabled: false,
|
||||
pingFrequency: 15,
|
||||
servers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const safeParse = (value: string | null): unknown => {
|
||||
if (value === null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const toStorageString = (value: unknown): string =>
|
||||
typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
const deepMerge = <T>(base: T, stored: unknown): T => {
|
||||
if (isPlainObject(base)) {
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
const storedObj = isPlainObject(stored) ? stored : {};
|
||||
for (const key of Object.keys(result)) {
|
||||
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 = {
|
||||
loadConfig(): Config {
|
||||
try {
|
||||
const stored = localStorage.getItem('config');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
return normalizeConfig(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing config from localStorage', error);
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
},
|
||||
|
||||
saveConfig(config: Config): void {
|
||||
localStorage.setItem('config', JSON.stringify(config));
|
||||
},
|
||||
|
||||
loadUserWallpapers(): Wallpaper[] {
|
||||
try {
|
||||
const stored = localStorage.getItem('userWallpapers');
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
saveUserWallpapers(wallpapers: Wallpaper[]): void {
|
||||
localStorage.setItem('userWallpapers', JSON.stringify(wallpapers));
|
||||
},
|
||||
|
||||
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
||||
const finalName = await addWallpaperToChromeStorageLocal(name, url);
|
||||
return { name: finalName };
|
||||
},
|
||||
|
||||
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file.size > 4 * 1024 * 1024) {
|
||||
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const base64 = reader.result as string;
|
||||
if (base64.length > 4.5 * 1024 * 1024) {
|
||||
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||
resolve({ name: finalName });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||
},
|
||||
|
||||
exportConfig(): void {
|
||||
const exportPayload = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
requiredLocalStorageKeys: [...REQUIRED_LOCAL_STORAGE_KEYS],
|
||||
localStorage: REQUIRED_LOCAL_STORAGE_KEYS.reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = safeParse(localStorage.getItem(key));
|
||||
return acc;
|
||||
},
|
||||
{} as Record<RequiredLocalStorageKey, unknown>,
|
||||
),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `vision-start-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
|
||||
const fileContent = await file.text();
|
||||
const parsed = JSON.parse(fileContent);
|
||||
const localStorageData =
|
||||
parsed?.localStorage && typeof parsed.localStorage === 'object'
|
||||
? parsed.localStorage
|
||||
: parsed;
|
||||
|
||||
if (!localStorageData || typeof localStorageData !== 'object') {
|
||||
throw new Error('Invalid import file format.');
|
||||
}
|
||||
|
||||
let importedAny = false;
|
||||
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
|
||||
const rawValue = (localStorageData as Record<string, unknown>)[key];
|
||||
localStorage.setItem(key, toStorageString(rawValue));
|
||||
importedAny = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!importedAny) {
|
||||
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||
}
|
||||
|
||||
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||
.userWallpapers;
|
||||
|
||||
return {
|
||||
config: normalizeConfig(importedConfig),
|
||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||
};
|
||||
},
|
||||
|
||||
resetWallpaperState(): void {
|
||||
localStorage.setItem(
|
||||
'wallpaperState',
|
||||
JSON.stringify({
|
||||
lastWallpaperChange: new Date().toISOString(),
|
||||
currentIndex: 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
164
components/utils/StorageLocalManager.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// TypeScript interface for window.chrome
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome?: {
|
||||
storage?: {
|
||||
local?: {
|
||||
set: (items: object, callback?: () => void) => void;
|
||||
get: (keys: string[] | string, callback: (items: { [key: string]: string }) => void) => void;
|
||||
remove: (keys: string | string[], callback?: () => void) => void;
|
||||
};
|
||||
};
|
||||
runtime?: {
|
||||
lastError?: { message: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let isChromeStorageLocalAvailable: boolean | null = null;
|
||||
|
||||
|
||||
/**
|
||||
* Checks if chrome.storage.local is available and caches the result.
|
||||
*/
|
||||
export function checkChromeStorageLocalAvailable(): boolean {
|
||||
if (isChromeStorageLocalAvailable !== null) return isChromeStorageLocalAvailable;
|
||||
isChromeStorageLocalAvailable =
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.chrome !== 'undefined' &&
|
||||
typeof window.chrome.storage !== 'undefined' &&
|
||||
typeof window.chrome.storage.local !== 'undefined';
|
||||
return isChromeStorageLocalAvailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new wallpaper to chrome.storage.local.
|
||||
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL.
|
||||
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
|
||||
* @param name Wallpaper name (string), used as a fallback.
|
||||
* @param url Wallpaper image URL (string) or base64 data URL.
|
||||
* @returns Promise<string> The name under which the wallpaper was stored.
|
||||
* @throws Error if chrome.storage.local is unavailable or if a name is not provided for a non-fetchable URL.
|
||||
*/
|
||||
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
|
||||
if (!checkChromeStorageLocalAvailable()) {
|
||||
throw new Error('chrome.storage.local is not available');
|
||||
}
|
||||
|
||||
if (url.startsWith('data:')) {
|
||||
// This is a base64 encoded image from a file upload.
|
||||
// The name is the file name.
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (window.chrome?.storage?.local) {
|
||||
window.chrome.storage.local.set({ [name]: url }, function () {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} 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 (window.chrome?.storage?.local) {
|
||||
window.chrome.storage.local.set({ [name]: url }, function () {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
}
|
||||
}).then(() => name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a specific wallpaper from chrome.storage.local by name.
|
||||
* @param name Wallpaper name (string)
|
||||
* @returns Promise<string | null> (base64 string or null)
|
||||
* @throws Error if chrome.storage.local is unavailable
|
||||
*/
|
||||
export async function getWallpaperFromChromeStorageLocal(name: string): Promise<string | null> {
|
||||
if (!checkChromeStorageLocalAvailable()) {
|
||||
throw new Error('chrome.storage.local is not available');
|
||||
}
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
if (window.chrome?.storage?.local) {
|
||||
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(result[name] || null);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a wallpaper from chrome.storage.local by name.
|
||||
* @param name Wallpaper name (string)
|
||||
* @returns Promise<void>
|
||||
* @throws Error if chrome.storage.local is unavailable
|
||||
*/
|
||||
export async function removeWallpaperFromChromeStorageLocal(name: string): Promise<void> {
|
||||
if (!checkChromeStorageLocalAvailable()) {
|
||||
throw new Error('chrome.storage.local is not available');
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (window.chrome?.storage?.local) {
|
||||
window.chrome.storage.local.remove(name, function () {
|
||||
if (window.chrome?.runtime?.lastError) {
|
||||
reject(new Error(window.chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reject(new Error('chrome.storage.local is not available'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,12 +14,16 @@ export const baseWallpapers: Wallpaper[] = [
|
||||
name: 'Beach',
|
||||
url: 'https://wallpapershome.com/images/pages/pic_h/615.jpg'
|
||||
},
|
||||
{
|
||||
name: 'Dark',
|
||||
url: 'https://i.imgur.com/qHlRO0s.jpeg'
|
||||
},
|
||||
{
|
||||
name: 'Mountain',
|
||||
url: 'https://i.imgur.com/yHfOZUd.jpeg'
|
||||
},
|
||||
{
|
||||
name: 'Waves',
|
||||
url: '/waves.jpg',
|
||||
url: 'https://i.imgur.com/E8uxZ7R.png',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
function request_image(url) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var img = new Image();
|
||||
img.onload = function() { resolve(img); };
|
||||
img.onerror = function() { reject(url); };
|
||||
var settled = false;
|
||||
function settleOk() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(failTimer);
|
||||
img.onload = img.onerror = null;
|
||||
resolve(img);
|
||||
}
|
||||
function settleFail() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(failTimer);
|
||||
img.onload = img.onerror = null;
|
||||
reject(url);
|
||||
}
|
||||
img.onload = settleOk;
|
||||
img.onerror = settleFail;
|
||||
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16);
|
||||
var failTimer = setTimeout(settleFail, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,8 +32,6 @@ function ping(url, multiplier) {
|
||||
resolve(delta);
|
||||
};
|
||||
request_image(url).then(response).catch(response);
|
||||
|
||||
setTimeout(function() { reject(Error('Timeout')); }, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
0
constants.tsx
Executable file → Normal file
BIN
extension/icons/vision-128.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
extension/icons/vision-16.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
extension/icons/vision-48.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
548
index.css
@@ -1 +1,549 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-liquid: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
html {
|
||||
background: #0f1720;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
background: #0f1720;
|
||||
color: white;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.vision-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow-x: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.06), transparent 28rem),
|
||||
radial-gradient(circle at 82% 20%, rgba(34, 211, 238, 0.05), transparent 26rem),
|
||||
linear-gradient(135deg, rgba(241, 245, 249, 0.04), rgba(15, 23, 42, 0.16) 52%, rgba(8, 13, 20, 0.32));
|
||||
}
|
||||
|
||||
.vision-shell::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -15;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 38%, rgba(2, 6, 23, 0.3) 100%),
|
||||
radial-gradient(ellipse at center, transparent 0%, rgba(2, 6, 23, 0.18) 78%, rgba(2, 6, 23, 0.36) 100%);
|
||||
}
|
||||
|
||||
.wallpaper-transition {
|
||||
transition: filter 0.7s var(--ease-liquid), opacity 0.7s var(--ease-liquid), transform 0.7s var(--ease-liquid);
|
||||
}
|
||||
|
||||
.wallpaper-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -30;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform: scale(1.015);
|
||||
will-change: filter, opacity, transform;
|
||||
}
|
||||
|
||||
.wallpaper-luminance {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -25;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 34%, rgba(5, 12, 20, 0.34)),
|
||||
radial-gradient(circle at 50% 38%, rgba(255, 255, 255, 0.05), transparent 28rem),
|
||||
radial-gradient(circle at 50% 100%, rgba(8, 13, 24, 0.36), transparent 36rem);
|
||||
}
|
||||
|
||||
.liquid-surface {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.04) 46%, rgba(15, 23, 42, 0.18)),
|
||||
rgba(8, 13, 23, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -10px 20px rgba(2, 6, 23, 0.12),
|
||||
0 12px 28px rgba(2, 6, 23, 0.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(112%);
|
||||
backdrop-filter: blur(10px) saturate(112%);
|
||||
}
|
||||
|
||||
.liquid-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(120deg, rgba(255, 255, 255, 0.1), transparent 34%),
|
||||
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.07), transparent 42%);
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.liquid-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04) 48%, rgba(5, 12, 22, 0.34)),
|
||||
rgba(8, 13, 23, 0.48);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 22px 56px rgba(2, 6, 23, 0.3);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(115%);
|
||||
backdrop-filter: blur(14px) saturate(115%);
|
||||
}
|
||||
|
||||
.liquid-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
color: white;
|
||||
transition:
|
||||
transform 180ms var(--ease-ios),
|
||||
background 180ms var(--ease-ios),
|
||||
border-color 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios),
|
||||
color 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-control:hover {
|
||||
transform: translateY(-1px) scale(1.025);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)),
|
||||
rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
0 14px 30px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
.liquid-control:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.liquid-focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.liquid-focus:focus-visible {
|
||||
outline: 2px solid rgba(34, 211, 238, 0.9);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.liquid-input {
|
||||
width: 100%;
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.075);
|
||||
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
transition:
|
||||
background 180ms var(--ease-ios),
|
||||
border-color 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-input::placeholder {
|
||||
color: rgba(226, 232, 240, 0.58);
|
||||
}
|
||||
|
||||
.liquid-input:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.liquid-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(34, 211, 238, 0.76);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(34, 211, 238, 0.16),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.liquid-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
transition:
|
||||
transform 150ms var(--ease-ios),
|
||||
filter 150ms var(--ease-ios),
|
||||
background 150ms var(--ease-ios),
|
||||
box-shadow 150ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-button:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.liquid-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.liquid-button-primary {
|
||||
background: linear-gradient(135deg, #06b6d4, #22d3ee);
|
||||
box-shadow: 0 14px 28px rgba(8, 145, 178, 0.22);
|
||||
}
|
||||
|
||||
.liquid-button-success {
|
||||
background: linear-gradient(135deg, #10b981, #22c55e);
|
||||
box-shadow: 0 14px 28px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.liquid-button-secondary {
|
||||
background: rgba(100, 116, 139, 0.74);
|
||||
}
|
||||
|
||||
.liquid-button-danger {
|
||||
background: linear-gradient(135deg, #ef4444, #fb7185);
|
||||
}
|
||||
|
||||
.liquid-range {
|
||||
--range-progress: 50%;
|
||||
width: clamp(10rem, 36vw, 13.5rem);
|
||||
height: 1.6rem;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
accent-color: #22d3ee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.liquid-range:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.liquid-range:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.liquid-range::-webkit-slider-runnable-track {
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78)) 0 / var(--range-progress) 100% no-repeat,
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
|
||||
rgba(8, 13, 23, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
|
||||
0 8px 18px rgba(2, 6, 23, 0.14);
|
||||
transition:
|
||||
border-color 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-range:hover::-webkit-slider-runnable-track {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 2px rgba(2, 6, 23, 0.2),
|
||||
0 10px 22px rgba(2, 6, 23, 0.18);
|
||||
}
|
||||
|
||||
.liquid-range::-webkit-slider-thumb {
|
||||
width: 1.18rem;
|
||||
height: 1.18rem;
|
||||
margin-top: -0.31rem;
|
||||
appearance: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
|
||||
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.1),
|
||||
0 8px 18px rgba(2, 6, 23, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
transform 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios),
|
||||
border-color 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-range:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.06);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(34, 211, 238, 0.15),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-range:active::-webkit-slider-thumb {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.liquid-range:focus-visible::-webkit-slider-thumb {
|
||||
border-color: rgba(34, 211, 238, 0.9);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.24),
|
||||
0 0 0 7px rgba(255, 255, 255, 0.08),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-range::-moz-range-track {
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
|
||||
rgba(8, 13, 23, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
|
||||
0 8px 18px rgba(2, 6, 23, 0.14);
|
||||
}
|
||||
|
||||
.liquid-range::-moz-range-progress {
|
||||
height: 0.68rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78));
|
||||
}
|
||||
|
||||
.liquid-range::-moz-range-thumb {
|
||||
width: 1.18rem;
|
||||
height: 1.18rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
|
||||
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.1),
|
||||
0 8px 18px rgba(2, 6, 23, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
transition:
|
||||
transform 180ms var(--ease-ios),
|
||||
box-shadow 180ms var(--ease-ios),
|
||||
border-color 180ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-range:hover::-moz-range-thumb {
|
||||
transform: scale(1.06);
|
||||
box-shadow:
|
||||
0 0 0 5px rgba(34, 211, 238, 0.15),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-range:active::-moz-range-thumb {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.liquid-range:focus-visible::-moz-range-thumb {
|
||||
border-color: rgba(34, 211, 238, 0.9);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(34, 211, 238, 0.24),
|
||||
0 0 0 7px rgba(255, 255, 255, 0.08),
|
||||
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.liquid-tile {
|
||||
border-radius: 1.35rem;
|
||||
transition:
|
||||
transform 240ms var(--ease-liquid),
|
||||
background 260ms var(--ease-ios),
|
||||
border-color 260ms var(--ease-ios),
|
||||
box-shadow 340ms var(--ease-liquid);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.liquid-tile:hover {
|
||||
transform: translateY(-3px) scale(1.025);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08) 46%, rgba(34, 211, 238, 0.08)),
|
||||
rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||
0 18px 34px rgba(2, 6, 23, 0.24),
|
||||
0 0 20px rgba(34, 211, 238, 0.04);
|
||||
}
|
||||
|
||||
.liquid-tile:active {
|
||||
transform: scale(0.965);
|
||||
}
|
||||
|
||||
.liquid-ghost-tile {
|
||||
border-radius: 1.35rem;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 255, 255, 0.32);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.liquid-ghost-tile:hover {
|
||||
color: white;
|
||||
border-color: rgba(34, 211, 238, 0.62);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(255, 255, 255, 0.04)),
|
||||
rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.liquid-edit-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -0.9rem;
|
||||
z-index: 5;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 999px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.liquid-edit-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
transition:
|
||||
color 150ms var(--ease-ios),
|
||||
background 150ms var(--ease-ios),
|
||||
transform 150ms var(--ease-ios);
|
||||
}
|
||||
|
||||
.liquid-edit-action:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.liquid-edit-action:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.liquid-category-title {
|
||||
text-shadow: 0 2px 16px rgba(2, 6, 23, 0.45);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.liquid-clock-text,
|
||||
.liquid-title-text {
|
||||
text-shadow:
|
||||
0 2px 18px rgba(2, 6, 23, 0.45),
|
||||
0 0 28px rgba(255, 255, 255, 0.12);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.liquid-drawer {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03) 38%, rgba(7, 12, 22, 0.44)),
|
||||
rgba(7, 12, 22, 0.68);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||
-26px 0 64px rgba(2, 6, 23, 0.3);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(112%);
|
||||
backdrop-filter: blur(16px) saturate(112%);
|
||||
}
|
||||
|
||||
.liquid-modal-backdrop {
|
||||
background:
|
||||
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.04), transparent 32rem),
|
||||
rgba(3, 7, 18, 0.72);
|
||||
-webkit-backdrop-filter: blur(6px) saturate(108%);
|
||||
backdrop-filter: blur(6px) saturate(108%);
|
||||
}
|
||||
|
||||
.liquid-modal-card {
|
||||
overflow: visible;
|
||||
animation: liquid-pop 240ms var(--ease-spring);
|
||||
}
|
||||
|
||||
.liquid-dropdown-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
animation: liquid-drop 150ms var(--ease-ios);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.liquid-status-dot {
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.1),
|
||||
0 0 18px currentColor;
|
||||
}
|
||||
|
||||
@keyframes liquid-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.75rem) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes liquid-drop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.35rem) scaleY(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
|
||||
.liquid-control:hover,
|
||||
.liquid-tile:hover,
|
||||
.liquid-button:hover,
|
||||
.liquid-edit-action:hover,
|
||||
.liquid-range:hover::-webkit-slider-thumb,
|
||||
.liquid-range:active::-webkit-slider-thumb,
|
||||
.liquid-range:hover::-moz-range-thumb,
|
||||
.liquid-range:active::-moz-range-thumb {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.liquid-range::-webkit-slider-runnable-track,
|
||||
.liquid-range::-webkit-slider-thumb,
|
||||
.liquid-range::-moz-range-track,
|
||||
.liquid-range::-moz-range-progress,
|
||||
.liquid-range::-moz-range-thumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
2
index.html
Executable file → Normal file
@@ -8,7 +8,7 @@
|
||||
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-black">
|
||||
<body class="bg-slate-950">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Vision Startpage",
|
||||
"version": "1.0",
|
||||
"description": "A beautiful and customizable startpage for your browser.",
|
||||
"version": "0.0.0",
|
||||
"description": "A light, modern and customizable startpage.",
|
||||
"chrome_url_overrides": {
|
||||
"newtab": "index.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"icons": {
|
||||
"16": "extension/icons/vision-16.png",
|
||||
"48": "extension/icons/vision-48.png",
|
||||
"128": "extension/icons/vision-128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||
}
|
||||
|
||||
13
nginx.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
gzip_types
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml
|
||||
text/css
|
||||
text/plain
|
||||
text/xml
|
||||
image/svg+xml;
|
||||
674
package-lock.json
generated
@@ -10,16 +10,16 @@
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"preact": "^10.26.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"playwright": "1.61.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.2",
|
||||
@@ -53,13 +53,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -109,14 +109,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
|
||||
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -125,6 +125,19 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-annotate-as-pure": {
|
||||
"version": "7.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
|
||||
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.27.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
|
||||
@@ -153,14 +166,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -185,9 +198,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
|
||||
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -205,9 +218,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -239,13 +252,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
|
||||
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.0"
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -254,14 +267,14 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"node_modules/@babel/plugin-syntax-jsx": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
|
||||
"integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
"@babel/helper-plugin-utils": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -270,14 +283,34 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"node_modules/@babel/plugin-transform-react-jsx": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
|
||||
"integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-plugin-utils": "^7.28.6",
|
||||
"@babel/plugin-syntax-jsx": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-development": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
|
||||
"integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-react-jsx": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -296,33 +329,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -330,14 +363,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
|
||||
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -808,9 +841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
|
||||
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -823,13 +856,121 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"node_modules/@preact/preset-vite": {
|
||||
"version": "2.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz",
|
||||
"integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-react-jsx": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
|
||||
"@prefresh/vite": "^2.4.11",
|
||||
"@rollup/pluginutils": "^5.0.0",
|
||||
"babel-plugin-transform-hook-names": "^1.0.2",
|
||||
"debug": "^4.4.3",
|
||||
"magic-string": "^0.30.21",
|
||||
"picocolors": "^1.1.1",
|
||||
"vite-prerender-plugin": "^0.5.8",
|
||||
"zimmerframe": "^1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "7.x",
|
||||
"vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@prefresh/babel-plugin": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz",
|
||||
"integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@prefresh/core": {
|
||||
"version": "1.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz",
|
||||
"integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.0.0 || ^11.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prefresh/utils": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz",
|
||||
"integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@prefresh/vite": {
|
||||
"version": "2.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz",
|
||||
"integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.22.1",
|
||||
"@prefresh/babel-plugin": "^0.5.2",
|
||||
"@prefresh/core": "^1.5.0",
|
||||
"@prefresh/utils": "^1.2.0",
|
||||
"@rollup/pluginutils": "^4.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.4.0 || ^11.0.0-0",
|
||||
"vite": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
|
||||
"integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"estree-walker": "^2.0.1",
|
||||
"picomatch": "^2.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prefresh/vite/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz",
|
||||
@@ -1366,51 +1507,6 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
"@types/babel__generator": "*",
|
||||
"@types/babel__template": "*",
|
||||
"@types/babel__traverse": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__generator": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
||||
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.1.0",
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__traverse": {
|
||||
"version": "7.20.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
|
||||
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1428,13 +1524,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
@@ -1443,27 +1549,6 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.21",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
||||
@@ -1502,6 +1587,23 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-transform-hook-names": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
|
||||
"integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.12.10"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.25.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
|
||||
@@ -1581,17 +1683,47 @@
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1615,6 +1747,65 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.187",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
|
||||
@@ -1635,6 +1826,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.6",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
|
||||
@@ -1686,6 +1890,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.4.6",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
|
||||
@@ -1744,6 +1955,16 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
@@ -1786,6 +2007,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kolorist": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
|
||||
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
@@ -2031,22 +2259,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.525.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
|
||||
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
@@ -2110,6 +2329,17 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-parser": {
|
||||
"version": "6.1.13",
|
||||
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
|
||||
"integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^5.1.0",
|
||||
"he": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||
@@ -2127,6 +2357,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2145,6 +2388,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -2180,6 +2470,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.0",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
|
||||
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
@@ -2191,6 +2491,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2200,6 +2501,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -2230,16 +2532,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
@@ -2289,7 +2581,8 @@
|
||||
"version": "0.26.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -2301,6 +2594,26 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-code-frame": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz",
|
||||
"integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kolorist": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2310,6 +2623,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "1.0.0-pre2",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz",
|
||||
"integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
|
||||
@@ -2499,6 +2822,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-prerender-plugin": {
|
||||
"version": "0.5.13",
|
||||
"resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz",
|
||||
"integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kolorist": "^1.8.0",
|
||||
"magic-string": "0.x >= 0.26.0",
|
||||
"node-html-parser": "^6.1.12",
|
||||
"simple-code-frame": "^1.3.0",
|
||||
"source-map": "^0.7.4",
|
||||
"stack-trace": "^1.0.0-pre2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "5.x || 6.x || 7.x || 8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
@@ -2507,6 +2848,13 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
package.json
Executable file → Normal file
@@ -6,21 +6,22 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"capture:screenshots": "node scripts/capture_screenshots.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"preact": "^10.26.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.10.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"playwright": "1.61.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.2",
|
||||
|
||||
262
project-context.md
Normal file
@@ -0,0 +1,262 @@
|
||||
---
|
||||
project_name: Vision Start
|
||||
date: 2026-07-08
|
||||
type: general_overview
|
||||
---
|
||||
|
||||
# Vision Start — Project Context
|
||||
|
||||
A general, non-normative overview of the **Vision Start** project: what it is, how it's structured, what features it offers, and which files do what. This document is intended as a map for humans and AI agents to orient themselves in the codebase. It deliberately avoids coding-style prescriptive rules.
|
||||
|
||||
---
|
||||
|
||||
## 1. What Is This Project?
|
||||
|
||||
**Vision Start** is a glassmorphism-styled, highly customizable **browser startpage** (new-tab page).
|
||||
|
||||
- Distributed as a **Chrome/Chromium extension** (Manifest V3) that overrides the new tab with `index.html`.
|
||||
- Also runnable as a **standalone web app** and shipped as a **Docker image** served via nginx.
|
||||
- Built with **React + TypeScript**, bundled with **Vite**, and styled with **Tailwind CSS v4** (using the `@preact/preset-vite` so Preact is the actual React runtime).
|
||||
- Persistent state lives in `localStorage` and, when available, `chrome.storage.local`.
|
||||
|
||||
Live instances / artifacts:
|
||||
- Public demo: `http://vision-start.ivanch.me`
|
||||
- Source: `https://gitea.com/ivan/vision-start.git`
|
||||
- Container registry: `git.ivanch.me/ivanch/vision-start`
|
||||
- Releases: `https://git.ivanch.me/ivanch/vision-start/releases/latest`
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
| Layer | Tech |
|
||||
|---|---|
|
||||
| Language | TypeScript (~5.7), target ES2020, strict mode |
|
||||
| UI runtime | Preact 10 (via `@preact/preset-vite`); types from `@types/react` 19 |
|
||||
| Bundler / dev server | Vite 6 |
|
||||
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
||||
| Drag & drop | `@hello-pangea/dnd` 18 |
|
||||
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) |
|
||||
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
||||
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
||||
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
||||
| Release screenshots | Playwright 1.61.1 + Chromium capture the deployed production page at a fixed 1280×800 viewport |
|
||||
|
||||
Entry points: `index.html` → `index.tsx` → `App.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Feature Overview
|
||||
|
||||
The startpage is composed of widgets and a configuration panel:
|
||||
|
||||
- **Website Tiles** — Bookmarks organized into categories. Each tile shows an icon + name and opens the configured URL. Tiles can be added, edited, deleted, and moved left/right within their own category (reordering) while in edit mode.
|
||||
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
|
||||
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||
- **Title** — Optional big header title (text + size configurable).
|
||||
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
||||
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
|
||||
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
||||
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
||||
|
||||
Performance notes:
|
||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
||||
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
|
||||
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||
- Icon metadata (`/icon-metadata.json`) is module-level cached and hydrated into each icon-picker instance, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
||||
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
```
|
||||
vision-start/
|
||||
├── App.tsx # Root React component; central state + handlers
|
||||
├── index.tsx # React root mount (ReactDOM.createRoot)
|
||||
├── index.html # HTML shell, links index.css, mounts #root
|
||||
├── index.css # Tailwind v4 import + liquid glass utilities, wallpaper overlays, and easing tokens
|
||||
├── vite-env.d.ts # Type declaration for CSS imports used by TypeScript verification
|
||||
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
||||
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
||||
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
||||
│
|
||||
├── components/
|
||||
│ ├── Clock.tsx # Header clock widget
|
||||
│ ├── Wallpaper.tsx # Background image renderer + rotation logic
|
||||
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
|
||||
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
||||
│ │
|
||||
│ ├── layout/
|
||||
│ │ ├── Header.tsx # Renders Clock + Title
|
||||
│ │ ├── CategoryGroup.tsx # Renders a category's title + its tiles + edit controls
|
||||
│ │ ├── EditButton.tsx # Top-left pencil toggle
|
||||
│ │ └── ConfigurationButton.tsx # Top-right gear button
|
||||
│ │
|
||||
│ ├── configuration/
|
||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
||||
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
||||
│ │
|
||||
│ └── utils/
|
||||
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
||||
│ ├── iconService.ts # getWebsiteIcon: fetch HTML, parse apple-touch-icon/icon, fallback to Google favicons
|
||||
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
||||
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper fetch/base64/URL storage
|
||||
│
|
||||
├── public/
|
||||
│ ├── favicon.ico
|
||||
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
|
||||
│
|
||||
├── screenshots/ # README/release screenshots (home, editing, configuration; regenerated at 1280×800)
|
||||
├── scripts/
|
||||
│ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL
|
||||
│ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots
|
||||
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
|
||||
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
|
||||
│
|
||||
├── .gitea/workflows/
|
||||
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
||||
│ ├── pull-request.yaml # On PR updates: build/zip, capture screenshots, and publish an inline visual preview
|
||||
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
||||
│
|
||||
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
|
||||
├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container
|
||||
├── .dockerignore
|
||||
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
||||
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
||||
├── tailwind.config.js # Content globs + safelist of dynamic w-/h- sizes
|
||||
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
|
||||
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
|
||||
├── package.json # Scripts: dev, build, preview
|
||||
├── README.md # Public-facing readme + feature list + roadmap
|
||||
└── .env.local # Local env (not tracked in context here)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Model & State
|
||||
|
||||
The shape of all persisted data lives in `types.ts`:
|
||||
|
||||
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
|
||||
- **`Server`** — `id`, `name`, `address`
|
||||
- **`Category`** — `id`, `name`, `websites: Website[]`
|
||||
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
||||
- **`Config`** — Everything else: title, wallpaper list + 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. 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):
|
||||
|
||||
| Key | Where | Contents |
|
||||
|---|---|---|
|
||||
| `config` | `localStorage` | The full `Config` JSON |
|
||||
| `categories` | `localStorage` | `Category[]` JSON |
|
||||
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
|
||||
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
||||
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data |
|
||||
|
||||
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Application Flow (high level)
|
||||
|
||||
1. `index.html` loads `index.tsx`, which mounts `<App/>` into `#root`.
|
||||
2. `App.tsx` initializes state from `localStorage` (`categories`) and `ConfigurationService.loadConfig()` (`config`), falling back to defaults.
|
||||
3. `useEffect` hooks persist `config` and `categories` back to `localStorage` whenever they change.
|
||||
4. The screen renders:
|
||||
- `<Wallpaper>` behind everything (fetches URL/base64 from base catalog or chrome.storage.local; rotates per frequency).
|
||||
- `<EditButton>` (top-left) and `<ConfigurationButton>` (top-right).
|
||||
- `<Header>` (clock + title).
|
||||
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
|
||||
- Optional `<ServerWidget>` if enabled.
|
||||
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
|
||||
5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Build, Release & Deployment
|
||||
|
||||
### Local development / preview
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # Vite dev server (note: PROJECT.md says prefer `npm run build` for real testing)
|
||||
npm run build # Production build → dist/
|
||||
npm run preview # Serve built dist/
|
||||
```
|
||||
|
||||
### Chrome extension install (manual)
|
||||
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
|
||||
|
||||
### Docker
|
||||
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
||||
|
||||
### CI/CD (Gitea Actions)
|
||||
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image.
|
||||
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
||||
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
|
||||
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
||||
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
|
||||
|
||||
Required CI secrets (referenced by the workflows): `REGISTRY_PASSWORD`, `HOST`, `USERNAME`, `KEY`, `PORT`, `STAGING_DIR`, `PROD_DIR`, `VIRUSTOTAL_APIKEY`.
|
||||
|
||||
External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||
- `https://raw.githubusercontent.com/homarr-labs/dashboard-icons/.../metadata.json` → `public/icon-metadata.json` (used by `WebsiteEditModal` for the icon picker; gitignored).
|
||||
|
||||
---
|
||||
|
||||
## 8. Notable Behaviors & Quirks
|
||||
|
||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||
- **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.
|
||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where Things Live (Quick Lookup)
|
||||
|
||||
| You want to find… | Look in… |
|
||||
|---|---|
|
||||
| The default config | `components/services/ConfigurationService.ts` (`DEFAULT_CONFIG`) |
|
||||
| The default seed bookmarks | `constants.tsx` (`DEFAULT_CATEGORIES`) |
|
||||
| Built-in wallpaper list | `components/utils/baseWallpapers.ts` |
|
||||
| Type definitions | `types.ts` |
|
||||
| Main app wiring (state, handlers, layout) | `App.tsx` |
|
||||
| Settings UI | `components/ConfigurationModal.tsx` + `components/configuration/*Tab.tsx` |
|
||||
| Wallpaper rendering/rotation | `components/Wallpaper.tsx` |
|
||||
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
||||
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
||||
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
||||
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
||||
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
|
||||
| Docker build | `Dockerfile` |
|
||||
|
||||
---
|
||||
|
||||
_Last updated: 2026-07-10. Generated as a general project overview; not a coding-style guide._
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
screenshots/configuration.png
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
screenshots/editing.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
screenshots/home.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
190
scripts/capture_screenshots.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const viewport = { width: 1280, height: 800 };
|
||||
const host = '127.0.0.1';
|
||||
const port = 4173;
|
||||
const configuredBaseUrl = process.env.SCREENSHOT_BASE_URL?.replace(/\/+$/, '');
|
||||
const baseUrl = configuredBaseUrl || `http://${host}:${port}`;
|
||||
const outputDirectory = process.env.SCREENSHOT_OUTPUT_DIR || 'screenshots';
|
||||
const demoDataPath = process.env.SCREENSHOT_DEMO_DATA || 'scripts/demoData.json';
|
||||
const viteCli = path.resolve('node_modules/vite/bin/vite.js');
|
||||
const imgurImageCache = new Map();
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
const waitForServer = async (preview) => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lastError;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (previewError) throw previewError;
|
||||
|
||||
if (preview && preview.exitCode !== null) {
|
||||
throw new Error(`Vite preview exited with code ${preview.exitCode}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(baseUrl);
|
||||
if (response.ok) return;
|
||||
lastError = new Error(`Vite preview returned ${response.status}.`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
throw new Error(`Screenshot target did not respond at ${baseUrl}. ${lastError?.message || ''}`.trim());
|
||||
};
|
||||
|
||||
const loadDemoData = async () => {
|
||||
const encodedData = JSON.parse(await readFile(demoDataPath, 'utf8'));
|
||||
|
||||
if (!encodedData || typeof encodedData !== 'object' || Array.isArray(encodedData)) {
|
||||
throw new Error(`${demoDataPath} must contain an object of base64-encoded localStorage values.`);
|
||||
}
|
||||
|
||||
return Object.entries(encodedData).map(([key, value]) => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${demoDataPath} contains a non-string value for ${key}.`);
|
||||
}
|
||||
|
||||
return { key, value: Buffer.from(value, 'base64').toString('utf8') };
|
||||
});
|
||||
};
|
||||
|
||||
const fulfillImgurRequest = async (route) => {
|
||||
const url = route.request().url();
|
||||
let image = imgurImageCache.get(url);
|
||||
|
||||
if (!image) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
image = {
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: response.headers.get('content-type') || 'image/jpeg',
|
||||
};
|
||||
imgurImageCache.set(url, image);
|
||||
} catch {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': image.contentType },
|
||||
body: image.body,
|
||||
});
|
||||
};
|
||||
|
||||
const stopPreview = async (preview) => {
|
||||
if (preview.exitCode !== null) return;
|
||||
|
||||
const exited = new Promise((resolve) => preview.once('exit', resolve));
|
||||
preview.kill('SIGTERM');
|
||||
await Promise.race([exited, delay(5_000)]);
|
||||
|
||||
if (preview.exitCode === null) preview.kill('SIGKILL');
|
||||
};
|
||||
|
||||
const assertPngDimensions = (image, filename) => {
|
||||
const width = image.readUInt32BE(16);
|
||||
const height = image.readUInt32BE(20);
|
||||
|
||||
if (width !== viewport.width || height !== viewport.height) {
|
||||
throw new Error(`${filename} was generated at ${width}x${height}, expected ${viewport.width}x${viewport.height}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const capture = async (page, filename) => {
|
||||
const image = await page.screenshot({
|
||||
path: path.join(outputDirectory, filename),
|
||||
type: 'png',
|
||||
fullPage: false,
|
||||
scale: 'css',
|
||||
animations: 'disabled',
|
||||
});
|
||||
|
||||
assertPngDimensions(image, filename);
|
||||
};
|
||||
|
||||
const loadPage = async (page) => {
|
||||
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('main').waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await delay(1_500);
|
||||
};
|
||||
|
||||
let previewError;
|
||||
const preview = configuredBaseUrl
|
||||
? undefined
|
||||
: spawn(
|
||||
process.execPath,
|
||||
[viteCli, 'preview', '--host', host, '--port', String(port), '--strictPort'],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
|
||||
if (preview) {
|
||||
preview.once('error', (error) => {
|
||||
previewError = error;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForServer(preview);
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
const demoData = await loadDemoData();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
viewport,
|
||||
screen: viewport,
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Sao_Paulo',
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
await context.route('https://i.imgur.com/**', fulfillImgurRequest);
|
||||
await context.addInitScript((storageItems) => {
|
||||
storageItems.forEach(({ key, value }) => localStorage.setItem(key, value));
|
||||
}, demoData);
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await loadPage(page);
|
||||
await capture(page, 'home.png');
|
||||
|
||||
await page.getByRole('button', { name: 'Edit page' }).click();
|
||||
await capture(page, 'editing.png');
|
||||
|
||||
await loadPage(page);
|
||||
await page.getByRole('button', { name: 'Open configuration' }).click();
|
||||
await page.getByRole('dialog').waitFor({ state: 'visible' });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const drawer = document.querySelector('.liquid-drawer');
|
||||
if (!drawer) return false;
|
||||
const { left, right } = drawer.getBoundingClientRect();
|
||||
return left < window.innerWidth && right <= window.innerWidth;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
await capture(page, 'configuration.png');
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
if (preview) await stopPreview(preview);
|
||||
}
|
||||
113
scripts/check_virustotal.sh
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to check a file against VirusTotal API
|
||||
# Requires: curl, jq
|
||||
# Environment variable: virustotal_apikey
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
|
||||
API_KEY="${virustotal_apikey}"
|
||||
BASE_URL="https://www.virustotal.com/api/v3"
|
||||
|
||||
# Check if API key is set
|
||||
if [ -z "$API_KEY" ]; then
|
||||
echo "Error: virustotal_apikey environment variable is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file exists
|
||||
if [ ! -f "$FILE_PATH" ]; then
|
||||
echo "Error: File $FILE_PATH not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if required tools are available
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo "Error: curl is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Error: jq is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading $FILE_PATH to VirusTotal for analysis..."
|
||||
|
||||
# Upload file to VirusTotal
|
||||
UPLOAD_RESPONSE=$(curl -s -X POST \
|
||||
-H "x-apikey: $API_KEY" \
|
||||
-F "file=@$FILE_PATH" \
|
||||
"$BASE_URL/files")
|
||||
|
||||
# Extract scan_id from response
|
||||
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.id')
|
||||
|
||||
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
|
||||
echo "Error: Failed to upload file or get scan ID"
|
||||
echo "Response: $UPLOAD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "File uploaded successfully. Scan ID: $SCAN_ID"
|
||||
echo "Waiting for analysis to complete..."
|
||||
|
||||
# Wait for analysis to complete and get results
|
||||
MAX_ATTEMPTS=60
|
||||
ATTEMPT=0
|
||||
SLEEP_INTERVAL=10
|
||||
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
echo "Checking analysis status (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
|
||||
|
||||
# Get scan report
|
||||
REPORT_RESPONSE=$(curl -s -X GET \
|
||||
-H "x-apikey: $API_KEY" \
|
||||
"$BASE_URL/analyses/$SCAN_ID")
|
||||
|
||||
# Check if analysis is complete
|
||||
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.status')
|
||||
|
||||
if [ "$RESPONSE_CODE" == "completed" ]; then
|
||||
# Analysis complete
|
||||
echo "Analysis completed!"
|
||||
|
||||
# Extract results
|
||||
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.malicious')
|
||||
SUSPICIOUS=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.suspicious')
|
||||
# The v3 analyses object has no 'total' field — compute it by summing all stat categories
|
||||
TOTAL=$(echo "$REPORT_RESPONSE" | jq '[.data.attributes.stats | to_entries[].value] | add')
|
||||
ANALYSIS_ID=$(echo "$REPORT_RESPONSE" | jq -r '.data.id')
|
||||
PERMALINK="https://www.virustotal.com/gui/file-analysis/${ANALYSIS_ID}"
|
||||
|
||||
echo "Analysis URL: $PERMALINK"
|
||||
echo "Detection ratio: $POSITIVES/$TOTAL"
|
||||
|
||||
# Check if file is safe
|
||||
if [ "$POSITIVES" -eq 0 ] && [ "$SUSPICIOUS" -eq 0 ]; then
|
||||
echo "✅ File is clean (no threats detected)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ File flagged: $POSITIVES malicious, $SUSPICIOUS suspicious (out of $TOTAL scanners)"
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$RESPONSE_CODE" == "queued" ]; then
|
||||
echo "File still queued for analysis..."
|
||||
elif [ "$RESPONSE_CODE" == "in-progress" ]; then
|
||||
echo "Analysis still in progress..."
|
||||
else
|
||||
echo "Unexpected response code: $RESPONSE_CODE"
|
||||
echo "Response: $REPORT_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
|
||||
sleep $SLEEP_INTERVAL
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Timeout: Analysis did not complete within expected time"
|
||||
exit 1
|
||||
6
scripts/demoData.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"categories": "W3siaWQiOiIxIiwibmFtZSI6IlNlYXJjaCIsIndlYnNpdGVzIjpbeyJpZCI6IjEiLCJuYW1lIjoiR29vZ2xlIiwidXJsIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbSIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1nb29nbGUuY29tJnN6PTEyOCIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQxODAxMDgiLCJuYW1lIjoiWW91VHViZSIsInVybCI6Imh0dHBzOi8vd3d3LnlvdXR1YmUuY29tLyIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy95b3V0dWJlLnN2ZyIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQyMjI2OTQiLCJuYW1lIjoiRHJpdmUiLCJ1cmwiOiJodHRwczovL2RyaXZlLmdvb2dsZS5jb20vZHJpdmUvdS8wL215LWRyaXZlIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dvb2dsZS1kcml2ZS5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDE0ODIwIiwibmFtZSI6IkdtYWlsIiwidXJsIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC91LzAvP3RhYj13bSNpbmJveCIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9nbWFpbC5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDI3NTYwIiwibmFtZSI6Ikxhc3QuZm0iLCJ1cmwiOiJodHRwczovL3d3dy5sYXN0LmZtL2hvbWUiLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49d3d3Lmxhc3QuZm0mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjEifV19LHsiaWQiOiIxNzUzMDQ0NDQ2NzU1IiwibmFtZSI6IkNvZGUiLCJ3ZWJzaXRlcyI6W3siaWQiOiIxNzUzMDQ0NDU5MjcyIiwibmFtZSI6IkdpdGh1YiIsInVybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9mZWVkIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dpdGh1Yi1saWdodC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4ODcwNjgzIiwibmFtZSI6IkFXUyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9hd3MtbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ0NDY3NTUifSx7ImlkIjoiMTc4MzcyODkxNTg1MyIsIm5hbWUiOiJEb2NrZXIgSHViIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2RvY2tlci5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4OTkyMzkwIiwibmFtZSI6IkZpZ21hIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2ZpZ21hLnN2ZyIsImNhdGVnb3J5SWQiOiIxNzUzMDQ0NDQ2NzU1In1dfSx7ImlkIjoiMTc1MzA0NDY2ODAxMiIsIm5hbWUiOiJNZWRpYSIsIndlYnNpdGVzIjpbeyJpZCI6IjE3NTMwNDQ3MDYwMjQiLCJuYW1lIjoiTmV0ZmxpeCIsInVybCI6Imh0dHA6Ly90di5oYXZlbi8iLCJpY29uIjoiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2hvbWFyci1sYWJzL2Rhc2hib2FyZC1pY29ucy9zdmcvbmV0ZmxpeC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzUzMDQ0NzQwOTg2IiwibmFtZSI6IlByaW1lIiwidXJsIjoiaHR0cDovL29tdi5oYXZlbiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9wcmltZS12aWRlby1hbHQtZGFyay5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzgzNzI4Nzg0MjU2IiwibmFtZSI6IkhCTyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9oYm8tbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc1MzA0NDgyODg0MCIsIm5hbWUiOiJUcmFrdCIsInVybCI6Imh0dHBzOi8vYXBwLnRyYWt0LnR2LyIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1hcHAudHJha3QudHYmc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc4MzcyODcyOTQyNCIsIm5hbWUiOiJMZXR0ZXJib3hkIiwidXJsIjoiaHR0cHM6Ly9sZXR0ZXJib3hkLmNvbS8iLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49bGV0dGVyYm94ZC5jb20mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifV19XQ==",
|
||||
"config": "eyJ0aXRsZSI6IiIsImN1cnJlbnRXYWxscGFwZXJzIjpbIkFic3RyYWN0IFJlZCJdLCJ3YWxscGFwZXJGcmVxdWVuY3kiOiIxZCIsIndhbGxwYXBlckJsdXIiOjAsIndhbGxwYXBlckJyaWdodG5lc3MiOjEwOCwid2FsbHBhcGVyT3BhY2l0eSI6MTAwLCJ0aXRsZVNpemUiOiJtZWRpdW0iLCJhbGlnbm1lbnQiOiJtaWRkbGUiLCJob3Jpem9udGFsQWxpZ25tZW50IjoibWlkZGxlIiwidGlsZVNpemUiOiJzbWFsbCIsImNsb2NrIjp7ImVuYWJsZWQiOnRydWUsInNpemUiOiJ0aW55IiwiZm9udCI6Im1vbm9zcGFjZSIsImZvcm1hdCI6Img6bW0gQSJ9LCJzZXJ2ZXJXaWRnZXQiOnsiZW5hYmxlZCI6dHJ1ZSwicGluZ0ZyZXF1ZW5jeSI6MTUsInNlcnZlcnMiOlt7ImlkIjoiMTc4MzcyOTE4MzU1MyIsIm5hbWUiOiJBZEd1YXJkIiwiYWRkcmVzcyI6Imh0dHBzOi8vZ29vZ2xlLmNvbSJ9LHsiaWQiOiIxNzgzNzI5MjI3NDQyIiwibmFtZSI6IlByb3htb3giLCJhZGRyZXNzIjoiaHR0cHM6Ly9nb29nbGUuY29tIn1dfX0=",
|
||||
"userWallpapers": "W3sibmFtZSI6ImRhcmstYWJzdHJhY3QtMjU2MHgxNDQwLWNvbnRlbXBvcmFyeS1kZXNpZ24tc2xlZWstbGluZXMtMjY0MjYuanBnIn1d",
|
||||
"wallpaperState": "eyJsYXN0V2FsbHBhcGVyQ2hhbmdlIjoiMjA0Ni0wNy0xMVQwMDoyMzozMS4zODdaIiwiY3VycmVudEluZGV4IjowfQ=="
|
||||
}
|
||||
1
scripts/prepare_release.sh
Normal file
@@ -0,0 +1 @@
|
||||
wget https://raw.githubusercontent.com/homarr-labs/dashboard-icons/refs/heads/main/metadata.json -O public/icon-metadata.json
|
||||
@@ -8,4 +8,15 @@ export default {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
safelist: [
|
||||
'w-[24px]', 'h-[24px]',
|
||||
'w-[28px]', 'h-[28px]',
|
||||
'w-[32px]', 'h-[32px]',
|
||||
'w-[34px]', 'h-[34px]',
|
||||
'w-[36px]', 'h-[36px]',
|
||||
'w-[40px]', 'h-[40px]',
|
||||
'w-[42px]', 'h-[42px]',
|
||||
'w-[48px]', 'h-[48px]',
|
||||
// add any other sizes you use
|
||||
],
|
||||
}
|
||||
0
tsconfig.json
Executable file → Normal file
25
types.ts
Executable file → Normal file
@@ -1,4 +1,3 @@
|
||||
|
||||
export interface Website {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,3 +23,27 @@ export interface Wallpaper {
|
||||
url?: string;
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
title: string;
|
||||
currentWallpapers: string[];
|
||||
wallpaperFrequency: string;
|
||||
wallpaperBlur: number;
|
||||
wallpaperBrightness: number;
|
||||
wallpaperOpacity: number;
|
||||
titleSize: string;
|
||||
alignment: string;
|
||||
horizontalAlignment: string;
|
||||
clock: {
|
||||
enabled: boolean;
|
||||
size: string;
|
||||
font: string;
|
||||
format: string;
|
||||
};
|
||||
serverWidget: {
|
||||
enabled: boolean;
|
||||
pingFrequency: number;
|
||||
servers: Server[];
|
||||
};
|
||||
tileSize?: string;
|
||||
}
|
||||
1
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
4
vite.config.ts
Executable file → Normal file
@@ -1,12 +1,12 @@
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import preact from '@preact/preset-vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [preact(), tailwindcss()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
|
||||