Compare commits
21 Commits
f35ea01c71
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ef643645a | |||
| 552379b2a6 | |||
| f9864072cf | |||
| 51341c33ca | |||
| 8b5c52dd1e | |||
| c3addb6d02 | |||
| 5597afc572 | |||
| 30372f800c | |||
| 9e738cc0d5 | |||
| b60c88e9b1 | |||
| fee538f044 | |||
| 48ec764880 | |||
| babd31548c | |||
| 95ae04ecd2 | |||
| 7a137abb66 | |||
| 023b2ffdc5 | |||
| c9dafd76d1 | |||
| 254d8e26b6 | |||
| c59b7edd82 | |||
| 52d954d9ca | |||
| 1dfbe74280 |
@@ -15,27 +15,44 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Vision Start
|
name: Build Vision Start
|
||||||
if: gitea.event_name == 'push'
|
|
||||||
runs-on: ubuntu-amd64
|
runs-on: ubuntu-amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Setup required tools
|
||||||
|
run: sudo apt-get install zip jq curl -y
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Package dist as zip
|
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
bash scripts/prepare_release.sh
|
||||||
zip -r ../vision-start-build.zip .
|
npm run build
|
||||||
|
|
||||||
- name: Upload build artifact
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
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
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: vision-start-build
|
name: release-zip
|
||||||
path: vision-start-build.zip
|
path: vision-start-${{ gitea.ref_name }}.zip
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
|
|||||||
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"
|
||||||
@@ -19,19 +19,46 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set version from tag
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Expected a vX.Y.Z tag, got: $RELEASE_TAG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
sed -i -e "s/\"version\": \"0\.0\.0\"/\"version\": \"$VERSION\"/" manifest.json
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Setup required tools
|
- name: Setup required tools
|
||||||
run: sudo apt-get install zip jq curl -y
|
run: sudo apt-get install zip jq curl -y
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
|
||||||
- name: Prepare release
|
|
||||||
run: |
|
run: |
|
||||||
bash scripts/prepare_release.sh
|
bash scripts/prepare_release.sh
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
|
run: |
|
||||||
|
cd vision-start
|
||||||
|
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -73,7 +100,7 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [build, virus-total-check]
|
needs: [build, virus-total-check, capture_screenshots]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -81,6 +108,11 @@ jobs:
|
|||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
|
- name: Download screenshot artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-screenshots
|
||||||
|
path: release-screenshots
|
||||||
- name: Release zip
|
- name: Release zip
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
@@ -91,7 +123,11 @@ jobs:
|
|||||||
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
|
||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
tag_name: ${{ gitea.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
files: vision-start-${{ gitea.ref_name }}.zip
|
files: |
|
||||||
|
vision-start-${{ gitea.ref_name }}.zip
|
||||||
|
release-screenshots/home.png
|
||||||
|
release-screenshots/editing.png
|
||||||
|
release-screenshots/configuration.png
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
@@ -101,6 +137,17 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set version from tag
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Expected a vX.Y.Z tag, got: $RELEASE_TAG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
sed -i -e "s/\"version\": \"0\.0\.0\"/\"version\": \"$VERSION\"/" manifest.json
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
echo "${{ secrets.REGISTRY_PASSWORD }}" \
|
||||||
@@ -136,3 +183,38 @@ jobs:
|
|||||||
cd ${{ secrets.PROD_DIR }}
|
cd ${{ secrets.PROD_DIR }}
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d --force-recreate
|
docker compose up -d --force-recreate
|
||||||
|
|
||||||
|
capture_screenshots:
|
||||||
|
name: Capture Vision Start Screenshots
|
||||||
|
runs-on: ubuntu-amd64
|
||||||
|
needs: deploy_vision_start
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright Chromium
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Capture release screenshots
|
||||||
|
env:
|
||||||
|
SCREENSHOT_BASE_URL: http://vision-start.ivanch.me
|
||||||
|
run: npm run capture:screenshots
|
||||||
|
|
||||||
|
- name: Upload release screenshots
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-screenshots
|
||||||
|
retention-days: 30
|
||||||
|
path: |
|
||||||
|
screenshots/home.png
|
||||||
|
screenshots/editing.png
|
||||||
|
screenshots/configuration.png
|
||||||
|
|||||||
39
AGENTS.md
@@ -14,28 +14,31 @@ Guidance for AI agents (and humans) working on Vision Start. Read before making
|
|||||||
|
|
||||||
## 1. Styling guidance
|
## 1. Styling guidance
|
||||||
|
|
||||||
The design language is **glassmorphism**: translucent black surfaces, backdrop blur, thin light borders, subtle shadows, cyan accents, and iOS-like easing.
|
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)
|
### Surface recipe (use consistently)
|
||||||
- **Cards / modals / panels:** `bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl shadow-lg`
|
- **Tiles / floating controls / light surfaces:** `liquid-surface` plus `liquid-control` / `liquid-tile` / `liquid-focus` as appropriate.
|
||||||
- **Inputs:** `bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400`
|
- **Cards / modals / panels:** `liquid-panel liquid-modal-card rounded-3xl` for centered modals; `liquid-drawer` for the settings drawer.
|
||||||
- **Floating buttons (corner):** `bg-black/25 backdrop-blur-md border border-white/10 rounded-xl ... hover:bg-white/25 active:scale-90`
|
- **Inputs:** `liquid-input p-3`; ranges use `liquid-range`.
|
||||||
- **Full-screen modal overlay:** `bg-black/90 backdrop-blur-sm` (modals) or `bg-black/60 backdrop-blur-sm` (drawers)
|
- **Buttons:** `liquid-button` plus one of `liquid-button-primary`, `liquid-button-success`, `liquid-button-secondary`, or `liquid-button-danger`.
|
||||||
- **Slide-in drawer:** `bg-black/50 backdrop-blur-xl border-l border-white/10`
|
- **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
|
### Color tokens
|
||||||
- Accent / focus / selection: `cyan-400` (focus ring), `cyan-500` (active/selected background)
|
- Accent / focus / selection: cyan via `liquid-focus`, `cyan-400`, `cyan-200`, and `liquid-button-primary`
|
||||||
- Confirm/Save: `green-500` → `hover:bg-green-400`
|
- Confirm/Save: `liquid-button-success`
|
||||||
- Cancel/secondary: `gray-600` → `hover:bg-gray-500`
|
- Cancel/secondary: `liquid-button-secondary`
|
||||||
- Destructive: `red-500` → `hover:bg-red-400`
|
- Destructive: `liquid-button-danger` or red text on `liquid-edit-action`
|
||||||
- Tertiary (export/import etc.): `slate-700` → `hover:bg-slate-600`
|
- Tertiary (export/import etc.): `liquid-button-secondary`
|
||||||
- Status: online `bg-green-500`, offline `bg-red-500`, pending `bg-gray-500`
|
- 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)
|
- Text: primary `text-white`, secondary `text-slate-300`/`text-slate-400`, muted `text-white/50` (in-app hover states)
|
||||||
|
|
||||||
### Motion
|
### Motion
|
||||||
- Use the custom easing tokens defined in `index.css`: `ease-ios` (default) and `ease-spring` (toggle knobs, drawer slides).
|
- 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), `duration-250`/`duration-300` (modals, drawers).
|
- Standard durations: `duration-150` (buttons), `duration-200` (tiles, toggles, icons), and `duration-300` (drawers/modals).
|
||||||
- Micro-interactions are encouraged: `hover:scale-[1.04]`, `active:scale-90` / `active:scale-95` / `active:scale-[0.96]`, `hover:bg-white/25`.
|
- 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 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 **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`.
|
||||||
@@ -45,7 +48,7 @@ The design language is **glassmorphism**: translucent black surfaces, backdrop b
|
|||||||
### Components with established conventions to reuse
|
### 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.
|
- `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.
|
- `ToggleSwitch` (`components/ToggleSwitch.tsx`) for boolean toggles — **always use it** instead of checkboxes.
|
||||||
- Modal pattern: backdrop + centered glass card (`bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl`), close on overlay click via `handleOverlayClick` (`if (e.target === e.currentTarget) onClose()`).
|
- 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 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`).
|
- New options' size presets follow the existing scale: `tiny | small | medium | large` (see `Header.tsx`, `WebsiteTile.tsx`).
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ A "feature" in this project typically means: a new widget, a new settings option
|
|||||||
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).
|
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
|
### 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 glass container, and use the surface/duration/easing tokens above.
|
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`.
|
14. New "editable" tiles/content reuse the `isEditing` mode and the edit/move/delete affordances pattern from `WebsiteTile.tsx` and `CategoryGroup.tsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -108,6 +111,6 @@ A "feature" in this project typically means: a new widget, a new settings option
|
|||||||
|
|
||||||
- Read `project-context.md`.
|
- Read `project-context.md`.
|
||||||
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
|
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
|
||||||
- Favor the established glass tokens (`bg-black/25`, `backdrop-blur-md`, `border-white/10`, `cyan-400/500`) over ad-hoc colors.
|
- 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`.
|
- 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.
|
- Update `project-context.md` after your change.
|
||||||
|
|||||||
14
App.tsx
@@ -192,7 +192,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<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`}
|
||||||
>
|
>
|
||||||
<Wallpaper
|
<Wallpaper
|
||||||
wallpaperNames={config.currentWallpapers}
|
wallpaperNames={config.currentWallpapers}
|
||||||
@@ -207,7 +207,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<Header config={config} />
|
<Header config={config} />
|
||||||
|
|
||||||
<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) => (
|
{categories.map((category) => (
|
||||||
<CategoryGroup
|
<CategoryGroup
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -230,12 +230,14 @@ const App: React.FC = () => {
|
|||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
setIsCategoryModalOpen(true);
|
setIsCategoryModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="text-white/50 hover:text-white active:scale-90 transition-all duration-150 ease-ios"
|
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
||||||
|
aria-label="Add category"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
|
<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 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"/>
|
<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>
|
</svg>
|
||||||
|
Add category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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
|
||||||
14
README.md
@@ -1,18 +1,18 @@
|
|||||||
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
|
<div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
|
||||||
Vision Start
|
<img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
||||||
A glassmorphism-looking like, modern and customizable startpage built with React.
|
A light, modern and customizable startpage built with React.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Vision Start is not yet available on Chrome Web Store, but it can be installed m
|
|||||||
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
|
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
|
||||||
* **Elegant Clock:** A clock because all startpages have one.
|
* **Elegant Clock:** A clock because all startpages have one.
|
||||||
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
||||||
* **Glassmorphism UI:** A modern and stylish interface with a frosted glass effect.
|
* **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.
|
* **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.
|
* **Settings:** A settings page to configure the startpage, with export/import functionality.
|
||||||
|
|
||||||
|
|||||||
@@ -19,31 +19,31 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" 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">
|
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Category Name"
|
placeholder="Category Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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>
|
||||||
<div className="flex justify-between items-center mt-8">
|
<div className="flex justify-between items-center mt-8">
|
||||||
<div>
|
<div>
|
||||||
{edit && (
|
{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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={() => onSave(name)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||||
Save
|
Save
|
||||||
</button>
|
</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
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
textShadow: '0 2px 4px rgba(0,0,0,0.5)',
|
|
||||||
fontFamily: config.clock.font,
|
fontFamily: config.clock.font,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-250 ease-ios ${
|
className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -141,21 +141,21 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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-spring transform ${
|
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
||||||
isVisible ? 'translate-x-0' : 'translate-x-full'
|
isVisible ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="p-8 flex-grow overflow-y-auto">
|
<div className="p-6 sm:p-8 flex-grow overflow-y-auto">
|
||||||
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
<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) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`px-4 py-2 text-lg font-semibold ${
|
className={`liquid-focus rounded-xl px-3 py-2 text-sm font-bold transition-all duration-200 ease-ios ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'text-cyan-400 border-b-2 border-cyan-400'
|
? 'bg-cyan-400/25 text-cyan-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.28)]'
|
||||||
: 'text-slate-400'
|
: 'text-slate-300 hover:bg-white/10 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
>
|
>
|
||||||
@@ -188,18 +188,18 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 border-t border-white/10">
|
<div className="p-6 sm:p-8 border-t border-white/10">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => ConfigurationService.exportConfig()}
|
onClick={() => ConfigurationService.exportConfig()}
|
||||||
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => importInputRef.current?.click()}
|
onClick={() => importInputRef.current?.click()}
|
||||||
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -211,19 +211,19 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
onChange={handleImportConfig}
|
onChange={handleImportConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
isSaving.current = true;
|
isSaving.current = true;
|
||||||
onSave(config);
|
onSave(config);
|
||||||
}}
|
}}
|
||||||
className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5"
|
||||||
>
|
>
|
||||||
Save & Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@@ -95,7 +95,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ul
|
<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-150"
|
className="liquid-panel liquid-dropdown-list absolute z-10 mt-2 w-full rounded-xl overflow-hidden"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
@@ -105,8 +105,8 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-ios flex items-center
|
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-ios flex items-center
|
||||||
${
|
${
|
||||||
isSelected(option.value)
|
isSelected(option.value)
|
||||||
? 'bg-cyan-500/20 text-cyan-300'
|
? 'bg-cyan-400/20 text-cyan-200'
|
||||||
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
|
: 'hover:bg-white/20 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected(option.value)}
|
aria-selected={isSelected(option.value)}
|
||||||
@@ -117,7 +117,6 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden input to mimic native select behavior for forms */}
|
|
||||||
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ interface ServerWidgetProps {
|
|||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'online':
|
case 'online':
|
||||||
return 'bg-green-500';
|
return 'bg-green-400 text-green-400';
|
||||||
case 'offline':
|
case 'offline':
|
||||||
return 'bg-red-500';
|
return 'bg-red-400 text-red-400';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-500';
|
return 'bg-slate-400 text-slate-400';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,13 +71,11 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-auto max-w-full">
|
<div className="fixed bottom-3 left-1/2 z-20 w-auto max-w-[calc(100%-1.5rem)] -translate-x-1/2">
|
||||||
<div className="flex items-center gap-4 bg-black/25 backdrop-blur-md border border-white/20 px-4 py-2 shadow-lg"
|
<div className="liquid-surface flex items-center gap-4 rounded-full px-4 py-2">
|
||||||
style={{ borderBottomLeftRadius: '0', borderBottomRightRadius: '0', borderTopLeftRadius: '16px', borderTopRightRadius: '15px', borderBottomWidth: '0' }}
|
|
||||||
>
|
|
||||||
{config.serverWidget.servers.map((server) => (
|
{config.serverWidget.servers.map((server) => (
|
||||||
<div key={server.id} className="flex items-center gap-2">
|
<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">
|
<span className="text-slate-100 text-sm font-medium">
|
||||||
{server.name}
|
{server.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className={`w-14 h-8 flex items-center rounded-full p-1 cursor-pointer transition-colors duration-200 ease-ios ${checked ? 'bg-cyan-500' : 'bg-gray-600'}`}
|
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}
|
onClick={handleToggle}
|
||||||
|
aria-pressed={checked}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`bg-white w-6 h-6 rounded-full shadow-md transform transition-transform duration-200 ease-spring ${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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ interface WallpaperProps {
|
|||||||
wallpaperVersion: number;
|
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 => {
|
const parseFrequencyToMs = (freq: string): number => {
|
||||||
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||||
const match = freq.match(/(\d+)(h|d)/);
|
const match = freq.match(/^(\d+)(h|d)$/);
|
||||||
if (!match) return 24 * 60 * 60 * 1000;
|
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||||
const value = parseInt(match[1], 10);
|
const value = parseInt(match[1], 10);
|
||||||
const unit = match[2];
|
const unit = match[2];
|
||||||
if (unit === 'h') return value * 60 * 60 * 1000;
|
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
||||||
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
||||||
return 24 * 60 * 60 * 1000;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
@@ -128,17 +131,18 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 -z-10 w-full h-full wallpaper-transition"
|
className="wallpaper-layer wallpaper-transition"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${imageUrl})`,
|
backgroundImage: `url(${imageUrl})`,
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||||
opacity: opacity / 100,
|
opacity: opacity / 100,
|
||||||
}}
|
}}
|
||||||
aria-label="Wallpaper background"
|
aria-label="Wallpaper background"
|
||||||
/>
|
/>
|
||||||
|
<div className="wallpaper-luminance" aria-hidden="true" />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,12 +33,16 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
const [icon, setIcon] = useState(website ? website.icon : '');
|
const [icon, setIcon] = useState(website ? website.icon : '');
|
||||||
const [iconQuery, setIconQuery] = useState('');
|
const [iconQuery, setIconQuery] = useState('');
|
||||||
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
|
||||||
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
|
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>(() => iconMetadataCache ?? []);
|
||||||
const [iconsFetched, setIconsFetched] = useState(false);
|
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||||
const debounceRef = useRef<number | null>(null);
|
const debounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const ensureIconMetadata = () => {
|
const ensureIconMetadata = () => {
|
||||||
if (iconMetadataCache || iconsFetched) return;
|
if (iconMetadataCache) {
|
||||||
|
setIconMetadata(iconMetadataCache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (iconsFetched) return;
|
||||||
setIconsFetched(true);
|
setIconsFetched(true);
|
||||||
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
fetch('/icon-metadata.json', { cache: 'force-cache' })
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -103,15 +107,15 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" 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">
|
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
<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 flex-col gap-4">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-24 w-24 bg-white/10 rounded-lg flex items-center justify-center">
|
<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">
|
<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>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
@@ -125,16 +129,16 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="URL"
|
placeholder="URL"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
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">
|
<div className="relative w-full">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -145,10 +149,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
setIconQuery(e.target.value);
|
setIconQuery(e.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={ensureIconMetadata}
|
onFocus={ensureIconMetadata}
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
className="liquid-input p-3"
|
||||||
/>
|
/>
|
||||||
{filteredIcons.length > 0 && (
|
{filteredIcons.length > 0 && (
|
||||||
<div className="absolute z-10 w-full bg-gray-800 rounded-lg mt-1 max-h-60 overflow-y-auto">
|
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
||||||
{filteredIcons.map(iconData => (
|
{filteredIcons.map(iconData => (
|
||||||
<div
|
<div
|
||||||
key={iconData.name}
|
key={iconData.name}
|
||||||
@@ -157,7 +161,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
setIcon(iconUrl);
|
setIcon(iconUrl);
|
||||||
setFilteredIcons([]);
|
setFilteredIcons([]);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer flex items-center p-2 hover:bg-gray-700"
|
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
||||||
@@ -170,7 +174,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={fetchIcon} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
||||||
Fetch
|
Fetch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,16 +182,16 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
<div className="flex justify-between items-center mt-8">
|
<div className="flex justify-between items-center mt-8">
|
||||||
<div>
|
<div>
|
||||||
{edit && (
|
{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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={handleSave} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={handleSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||||
Save
|
Save
|
||||||
</button>
|
</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
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,53 +59,48 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Simulate loading time (dev purpose)
|
|
||||||
// e.preventDefault();
|
|
||||||
// setTimeout(() => {
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }, 3500); // Small delay to show spinner before navigation
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
||||||
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios`}>
|
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios ${isEditing ? 'mb-4' : ''}`}>
|
||||||
<a
|
<a
|
||||||
href={isEditing ? undefined : website.url}
|
href={isEditing ? undefined : website.url}
|
||||||
target="_self"
|
target="_self"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={handleClick}
|
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-200 ease-ios hover:scale-[1.04] active:scale-[0.96] 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 && (
|
{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">
|
<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>
|
<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>
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`flex items-center transition-all duration-200 ease-ios ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
|
<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 ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||||
<img src={website.icon} alt={`${website.name} icon`} className={`object-contain w-full h-full`} />
|
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-200 ease-ios ${isLoading ? 'text-sm' : ''}`}>
|
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
|
||||||
{website.name}
|
{website.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
|
<div className="liquid-surface liquid-edit-toolbar">
|
||||||
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-left" viewBox="0 0 16 16">
|
<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 fill-rule="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" />
|
<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>
|
</svg></button>
|
||||||
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
<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" />
|
<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>
|
</svg></button>
|
||||||
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-right" viewBox="0 0 16 16">
|
<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 fill-rule="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" />
|
<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>
|
</svg></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={config.clock.enabled}
|
checked={config.clock.enabled}
|
||||||
onChange={(checked) => updateClock({ enabled: checked })}
|
onChange={(checked) => updateClock({ enabled: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="clock.size"
|
name="clock.size"
|
||||||
@@ -36,7 +36,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="clock.font"
|
name="clock.font"
|
||||||
@@ -50,7 +50,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="clock.format"
|
name="clock.format"
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={config.title}
|
value={config.title}
|
||||||
onChange={(e) => onChange({ title: e.target.value })}
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="titleSize"
|
name="titleSize"
|
||||||
@@ -33,7 +33,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="alignment"
|
name="alignment"
|
||||||
@@ -46,7 +46,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="tileSize"
|
name="tileSize"
|
||||||
@@ -59,7 +59,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="horizontalAlignment"
|
name="horizontalAlignment"
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ interface ServerWidgetTabProps {
|
|||||||
onChange: (updates: Partial<Config>) => void;
|
onChange: (updates: Partial<Config>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||||
|
|
||||||
|
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||||
|
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||||
|
return { '--range-progress': `${progress}%` };
|
||||||
|
};
|
||||||
|
|
||||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
const [newServerName, setNewServerName] = useState('');
|
const [newServerName, setNewServerName] = useState('');
|
||||||
const [newServerAddress, setNewServerAddress] = useState('');
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
@@ -44,7 +51,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={config.serverWidget.enabled}
|
checked={config.serverWidget.enabled}
|
||||||
@@ -53,7 +60,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
{config.serverWidget.enabled && (
|
{config.serverWidget.enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -62,9 +69,10 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
max="60"
|
max="60"
|
||||||
value={config.serverWidget.pingFrequency}
|
value={config.serverWidget.pingFrequency}
|
||||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
||||||
/>
|
/>
|
||||||
<span>{config.serverWidget.pingFrequency}s</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -84,7 +92,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
className="liquid-surface flex items-center justify-between rounded-xl p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{server.name}</p>
|
<p className="font-semibold">{server.name}</p>
|
||||||
@@ -92,7 +100,8 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveServer(server.id)}
|
onClick={() => handleRemoveServer(server.id)}
|
||||||
className="text-red-500 hover:text-red-400"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
|
aria-label={`Remove ${server.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -118,24 +127,24 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex flex-col gap-2 mt-3 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Server Name"
|
placeholder="Server Name"
|
||||||
value={newServerName}
|
value={newServerName}
|
||||||
onChange={(e) => setNewServerName(e.target.value)}
|
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"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="HTTP Address"
|
placeholder="HTTP Address"
|
||||||
value={newServerAddress}
|
value={newServerAddress}
|
||||||
onChange={(e) => setNewServerAddress(e.target.value)}
|
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"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddServer}
|
onClick={handleAddServer}
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,31 @@ interface ThemeTabProps {
|
|||||||
onNextWallpaper: () => 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> = ({
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -28,6 +53,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||||
|
|
||||||
const handleAddWallpaper = async () => {
|
const handleAddWallpaper = async () => {
|
||||||
if (newWallpaperUrl.trim() === '') return;
|
if (newWallpaperUrl.trim() === '') return;
|
||||||
@@ -55,7 +81,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="currentWallpapers"
|
name="currentWallpapers"
|
||||||
@@ -66,24 +92,30 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
||||||
<Dropdown
|
<div className="flex items-center gap-4">
|
||||||
name="wallpaperFrequency"
|
<input
|
||||||
value={config.wallpaperFrequency}
|
type="range"
|
||||||
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
|
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||||
options={[
|
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||||
{ value: '1h', label: '1 hour' },
|
step="1"
|
||||||
{ value: '3h', label: '3 hours' },
|
value={wallpaperFrequencyHours}
|
||||||
{ value: '6h', label: '6 hours' },
|
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
||||||
{ value: '12h', label: '12 hours' },
|
className="liquid-range"
|
||||||
{ value: '1d', label: '1 day' },
|
style={getRangeStyle(
|
||||||
{ value: '2d', label: '2 days' },
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -92,12 +124,13 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
max="50"
|
max="50"
|
||||||
value={config.wallpaperBlur}
|
value={config.wallpaperBlur}
|
||||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
||||||
/>
|
/>
|
||||||
<span>{config.wallpaperBlur}px</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -106,12 +139,13 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
max="200"
|
max="200"
|
||||||
value={config.wallpaperBrightness}
|
value={config.wallpaperBrightness}
|
||||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
||||||
/>
|
/>
|
||||||
<span>{config.wallpaperBrightness}%</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -120,9 +154,10 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
max="100"
|
max="100"
|
||||||
value={config.wallpaperOpacity}
|
value={config.wallpaperOpacity}
|
||||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
||||||
/>
|
/>
|
||||||
<span>{config.wallpaperOpacity}%</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{chromeStorageAvailable && (
|
{chromeStorageAvailable && (
|
||||||
@@ -133,12 +168,13 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
{userWallpapers.map((wallpaper) => (
|
{userWallpapers.map((wallpaper) => (
|
||||||
<div
|
<div
|
||||||
key={wallpaper.name}
|
key={wallpaper.name}
|
||||||
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
|
||||||
>
|
>
|
||||||
<span className="truncate">{wallpaper.name}</span>
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteWallpaper(wallpaper)}
|
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||||
className="text-red-500 hover:text-red-400"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
|
aria-label={`Delete ${wallpaper.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -167,19 +203,19 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
placeholder="Wallpaper Name (optional for URLs)"
|
placeholder="Wallpaper Name (optional for URLs)"
|
||||||
value={newWallpaperName}
|
value={newWallpaperName}
|
||||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
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"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Image URL"
|
placeholder="Image URL"
|
||||||
value={newWallpaperUrl}
|
value={newWallpaperUrl}
|
||||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
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"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddWallpaper}
|
onClick={handleAddWallpaper}
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 active:scale-95 text-white font-bold py-2 px-4 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -187,7 +223,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
<label
|
<label
|
||||||
htmlFor="file-upload"
|
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"
|
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">
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
<svg
|
<svg
|
||||||
@@ -227,7 +263,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onNextWallpaper}
|
onClick={onNextWallpaper}
|
||||||
disabled={config.currentWallpapers.length === 0}
|
disabled={config.currentWallpapers.length === 0}
|
||||||
className="flex items-center gap-2 bg-black/25 backdrop-blur-md border border-white/10 hover:bg-white/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-xl transition-all duration-150 ease-ios"
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ interface CategoryGroupProps {
|
|||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAddTileSizeClass = (size: string | undefined) => {
|
||||||
|
switch (size) {
|
||||||
|
case 'small':
|
||||||
|
return 'w-28 h-28';
|
||||||
|
case 'medium':
|
||||||
|
return 'w-32 h-32';
|
||||||
|
case 'large':
|
||||||
|
return 'w-36 h-36';
|
||||||
|
default:
|
||||||
|
return 'w-32 h-32';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
category,
|
category,
|
||||||
isEditing,
|
isEditing,
|
||||||
@@ -29,23 +42,24 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="w-full">
|
<div key={category.id} className="w-full">
|
||||||
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-4 w-full ${horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
|
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||||
<h2 className={`text-2xl font-bold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCategory(category);
|
setEditingCategory(category);
|
||||||
setIsCategoryModalOpen(true);
|
setIsCategoryModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className={`ml-2 text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
|
aria-label={`Edit ${category.name} category`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-6`}>
|
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||||
{category.websites.map((website) => (
|
{category.websites.map((website) => (
|
||||||
<WebsiteTile
|
<WebsiteTile
|
||||||
key={website.id}
|
key={website.id}
|
||||||
@@ -59,12 +73,14 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddingWebsite(category)}
|
onClick={() => setAddingWebsite(category)}
|
||||||
className={`text-white/50 hover:text-white transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
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="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
|
<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 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" />
|
<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>
|
</svg>
|
||||||
|
<span className="text-sm font-bold">Add</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ interface ConfigurationButtonProps {
|
|||||||
|
|
||||||
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
|
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 right-4">
|
<div className="absolute top-4 right-4 z-20">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
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 active:scale-90 transition-all duration-200 ease-ios"
|
className="liquid-surface liquid-control liquid-focus rounded-2xl p-3"
|
||||||
|
aria-label="Open configuration"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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"/>
|
<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"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ interface EditButtonProps {
|
|||||||
|
|
||||||
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 left-4">
|
<div className="absolute top-4 left-4 z-20">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
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 active:scale-90 transition-all duration-200 ease-ios"
|
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||||
style={{ fontSize: '12px' }}
|
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
<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"/>
|
<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>
|
</svg>
|
||||||
{isEditing ? 'Done' : ''}
|
{isEditing ? 'Done' : ''}
|
||||||
|
|||||||
@@ -56,16 +56,15 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{config.clock.enabled && (
|
{config.clock.enabled && (
|
||||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto p-2">
|
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
||||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||||
{config.title && (
|
{config.title && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1
|
<h1
|
||||||
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
|
className={`liquid-title-text ${getTitleSizeClass(config.titleSize)} font-extrabold text-white mb-2 mt-3`}
|
||||||
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
>
|
||||||
{config.title}
|
{config.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
|||||||
|
|
||||||
export const DEFAULT_CONFIG: Config = {
|
export const DEFAULT_CONFIG: Config = {
|
||||||
title: 'Vision Start',
|
title: 'Vision Start',
|
||||||
currentWallpapers: ['Abstract'],
|
currentWallpapers: ['Beach'],
|
||||||
wallpaperFrequency: '1d',
|
wallpaperFrequency: '1d',
|
||||||
wallpaperBlur: 0,
|
wallpaperBlur: 0,
|
||||||
wallpaperBrightness: 100,
|
wallpaperBrightness: 108,
|
||||||
wallpaperOpacity: 100,
|
wallpaperOpacity: 96,
|
||||||
titleSize: 'medium',
|
titleSize: 'medium',
|
||||||
alignment: 'middle',
|
alignment: 'middle',
|
||||||
horizontalAlignment: 'middle',
|
horizontalAlignment: 'middle',
|
||||||
@@ -43,13 +43,37 @@ const safeParse = (value: string | null): unknown => {
|
|||||||
const toStorageString = (value: unknown): string =>
|
const toStorageString = (value: unknown): string =>
|
||||||
typeof value === 'string' ? value : JSON.stringify(value);
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const deepMerge = <T>(base: T, stored: unknown): T => {
|
||||||
|
if (isPlainObject(base)) {
|
||||||
|
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||||
|
const storedObj = isPlainObject(stored) ? stored : {};
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(storedObj, key)) {
|
||||||
|
result[key] = deepMerge(result[key], storedObj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
if (Array.isArray(base)) {
|
||||||
|
return (Array.isArray(stored) ? stored : base) as T;
|
||||||
|
}
|
||||||
|
if (stored === null) return base;
|
||||||
|
return typeof stored === typeof base ? (stored as T) : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeConfig = (stored: unknown): Config =>
|
||||||
|
deepMerge(DEFAULT_CONFIG, stored);
|
||||||
|
|
||||||
export const ConfigurationService = {
|
export const ConfigurationService = {
|
||||||
loadConfig(): Config {
|
loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('config');
|
const stored = localStorage.getItem('config');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
return { ...DEFAULT_CONFIG, ...parsed };
|
return normalizeConfig(parsed);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing config from localStorage', error);
|
console.error('Error parsing config from localStorage', error);
|
||||||
@@ -159,12 +183,12 @@ export const ConfigurationService = {
|
|||||||
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
|
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||||
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||||
.userWallpapers as Wallpaper[];
|
.userWallpapers;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: importedConfig || { ...DEFAULT_CONFIG },
|
config: normalizeConfig(importedConfig),
|
||||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
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 |
541
index.css
@@ -3,8 +3,547 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--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 {
|
.wallpaper-transition {
|
||||||
transition: filter 0.3s ease, opacity 0.3s ease;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black">
|
<body class="bg-slate-950">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/index.tsx"></script>
|
<script type="module" src="/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Vision Startpage",
|
"name": "Vision Startpage",
|
||||||
"version": "1.0",
|
"version": "0.0.0",
|
||||||
"description": "A beautiful and customizable startpage for your browser.",
|
"description": "A light, modern and customizable startpage.",
|
||||||
"chrome_url_overrides": {
|
"chrome_url_overrides": {
|
||||||
"newtab": "index.html"
|
"newtab": "index.html"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "extension/icons/vision-16.png",
|
||||||
|
"48": "extension/icons/vision-48.png",
|
||||||
|
"128": "extension/icons/vision-128.png"
|
||||||
|
},
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||||
}
|
}
|
||||||
|
|||||||
48
package-lock.json
generated
@@ -19,6 +19,7 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"playwright": "1.61.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
@@ -2387,6 +2388,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"capture:screenshots": "node scripts/capture_screenshots.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"playwright": "1.61.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
project_name: Vision Start
|
project_name: Vision Start
|
||||||
date: 2026-07-02
|
date: 2026-07-08
|
||||||
type: general_overview
|
type: general_overview
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ Live instances / artifacts:
|
|||||||
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
||||||
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
||||||
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
||||||
|
| Release screenshots | Playwright 1.61.1 + Chromium capture the deployed production page at a fixed 1280×800 viewport |
|
||||||
|
|
||||||
Entry points: `index.html` → `index.tsx` → `App.tsx`.
|
Entry points: `index.html` → `index.tsx` → `App.tsx`.
|
||||||
|
|
||||||
@@ -54,13 +55,13 @@ The startpage is composed of widgets and a configuration panel:
|
|||||||
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||||
- **Title** — Optional big header title (text + size configurable).
|
- **Title** — Optional big header title (text + size configurable).
|
||||||
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity. Supports rotating through multiple wallpapers at a cadence (`1h`–`2d`).
|
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. 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`).
|
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; 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.
|
- **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).
|
- **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 move/edit buttons, per-category edit buttons, and "add" buttons.
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
||||||
- **Glassmorphism design language** — Translucent surfaces, `backdrop-blur`, subtle borders, and custom iOS-like easing tokens (`ease-ios`, `ease-spring`) defined in `index.css`.
|
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
||||||
|
|
||||||
Performance notes:
|
Performance notes:
|
||||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
- 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.
|
||||||
@@ -68,8 +69,8 @@ Performance notes:
|
|||||||
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||||
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||||
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||||
- Icon metadata (`/icon-metadata.json`) is module-level cached, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
- Icon metadata (`/icon-metadata.json`) is 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` and its CSS transition lives in the static `.wallpaper-transition` class in `index.css`.
|
- `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`):
|
Planned / To-do (tracked in `README.md`):
|
||||||
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
@@ -83,11 +84,11 @@ vision-start/
|
|||||||
├── App.tsx # Root React component; central state + handlers
|
├── App.tsx # Root React component; central state + handlers
|
||||||
├── index.tsx # React root mount (ReactDOM.createRoot)
|
├── index.tsx # React root mount (ReactDOM.createRoot)
|
||||||
├── index.html # HTML shell, links index.css, mounts #root
|
├── index.html # HTML shell, links index.css, mounts #root
|
||||||
├── index.css # Tailwind v4 import + custom easing tokens
|
├── 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)
|
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
||||||
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
||||||
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
||||||
├── icon.png # Extension icon source
|
|
||||||
│
|
│
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── Clock.tsx # Header clock widget
|
│ ├── Clock.tsx # Header clock widget
|
||||||
@@ -108,7 +109,7 @@ vision-start/
|
|||||||
│ │
|
│ │
|
||||||
│ ├── configuration/
|
│ ├── configuration/
|
||||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||||
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity
|
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
||||||
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||||
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||||
│ │
|
│ │
|
||||||
@@ -125,13 +126,16 @@ vision-start/
|
|||||||
│ ├── favicon.ico
|
│ ├── favicon.ico
|
||||||
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
|
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
|
||||||
│
|
│
|
||||||
├── screenshots/ # README screenshots (dark page, editing, configuration)
|
├── screenshots/ # README/release screenshots (home, editing, configuration; regenerated at 1280×800)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
|
│ ├── capture_screenshots.mjs # Loads demoData, captures home/edit/configuration with Playwright, and can target a deployed URL
|
||||||
|
│ ├── demoData.json # Base64-encoded localStorage fixture used for release screenshots
|
||||||
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
|
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
|
||||||
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
|
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
|
||||||
│
|
│
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
||||||
|
│ ├── pull-request.yaml # On PR updates: build/zip, capture screenshots, and publish an inline visual preview
|
||||||
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
||||||
│
|
│
|
||||||
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
|
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
|
||||||
@@ -157,9 +161,9 @@ The shape of all persisted data lives in `types.ts`:
|
|||||||
- **`Server`** — `id`, `name`, `address`
|
- **`Server`** — `id`, `name`, `address`
|
||||||
- **`Category`** — `id`, `name`, `websites: Website[]`
|
- **`Category`** — `id`, `name`, `websites: Website[]`
|
||||||
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
||||||
- **`Config`** — Everything else: title, wallpaper list + frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
- **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
||||||
|
|
||||||
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers.
|
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
|
||||||
|
|
||||||
Storage layout (browser-side):
|
Storage layout (browser-side):
|
||||||
|
|
||||||
@@ -208,6 +212,9 @@ Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked"
|
|||||||
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
||||||
|
|
||||||
### CI/CD (Gitea Actions)
|
### CI/CD (Gitea Actions)
|
||||||
|
- `release.yaml` validates each `vX.Y.Z` tag and stamps the version into `manifest.json` (`"version": "0.0.0"` → the tag) in each build checkout before producing the extension archive and production image.
|
||||||
|
- **`pull-request.yaml`** — Triggers on pull request open, reopen, and synchronization. It builds and uploads a PR extension archive containing `dist/`, unpacks that archive in a separate Playwright job to generate the three demo screenshots, and uploads both artifacts (screenshots are retained for 30 days). For same-repository PRs, it maintains one Gitea PR comment with inline image attachments; fork PRs retain artifacts but skip the comment because their workflow token is read-only.
|
||||||
|
- After `deploy_vision_start` succeeds, `release.yaml` uses Playwright Chromium against the deployed production page and seeds each browser context from `scripts/demoData.json`. It regenerates `home.png`, `editing.png`, and `configuration.png` at exactly 1280×800, uploads them as artifacts (retained for 30 days), and attaches them as individual Gitea release assets.
|
||||||
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
||||||
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
|
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
|
||||||
|
|
||||||
@@ -223,7 +230,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
||||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
||||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **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.
|
- **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.
|
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||||
@@ -247,9 +254,9 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
||||||
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
||||||
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
||||||
| Release packaging | `scripts/prepare_release.sh`, `scripts/check_virustotal.sh`, `.gitea/workflows/release.yaml` |
|
| Build/release/PR pipelines | `scripts/prepare_release.sh`, `scripts/capture_screenshots.mjs`, `scripts/check_virustotal.sh`, `.gitea/workflows/pull-request.yaml`, `.gitea/workflows/release.yaml` |
|
||||||
| Docker build | `Dockerfile` |
|
| Docker build | `Dockerfile` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-07-02. Generated as a general project overview; not a coding-style guide._
|
_Last updated: 2026-07-10. Generated as a general project overview; not a coding-style guide._
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 744 KiB |
BIN
screenshots/configuration.png
Normal file
|
After Width: | Height: | Size: 479 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
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);
|
||||||
|
}
|
||||||
6
scripts/demoData.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"categories": "W3siaWQiOiIxIiwibmFtZSI6IlNlYXJjaCIsIndlYnNpdGVzIjpbeyJpZCI6IjEiLCJuYW1lIjoiR29vZ2xlIiwidXJsIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbSIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1nb29nbGUuY29tJnN6PTEyOCIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQxODAxMDgiLCJuYW1lIjoiWW91VHViZSIsInVybCI6Imh0dHBzOi8vd3d3LnlvdXR1YmUuY29tLyIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy95b3V0dWJlLnN2ZyIsImNhdGVnb3J5SWQiOiIxIn0seyJpZCI6IjE3NTMwNDQyMjI2OTQiLCJuYW1lIjoiRHJpdmUiLCJ1cmwiOiJodHRwczovL2RyaXZlLmdvb2dsZS5jb20vZHJpdmUvdS8wL215LWRyaXZlIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dvb2dsZS1kcml2ZS5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDE0ODIwIiwibmFtZSI6IkdtYWlsIiwidXJsIjoiaHR0cHM6Ly9tYWlsLmdvb2dsZS5jb20vbWFpbC91LzAvP3RhYj13bSNpbmJveCIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9nbWFpbC5zdmciLCJjYXRlZ29yeUlkIjoiMSJ9LHsiaWQiOiIxNzUzMDQ0NDI3NTYwIiwibmFtZSI6Ikxhc3QuZm0iLCJ1cmwiOiJodHRwczovL3d3dy5sYXN0LmZtL2hvbWUiLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49d3d3Lmxhc3QuZm0mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjEifV19LHsiaWQiOiIxNzUzMDQ0NDQ2NzU1IiwibmFtZSI6IkNvZGUiLCJ3ZWJzaXRlcyI6W3siaWQiOiIxNzUzMDQ0NDU5MjcyIiwibmFtZSI6IkdpdGh1YiIsInVybCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9mZWVkIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2dpdGh1Yi1saWdodC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4ODcwNjgzIiwibmFtZSI6IkFXUyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9hd3MtbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ0NDY3NTUifSx7ImlkIjoiMTc4MzcyODkxNTg1MyIsIm5hbWUiOiJEb2NrZXIgSHViIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2RvY2tlci5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDQ0Njc1NSJ9LHsiaWQiOiIxNzgzNzI4OTkyMzkwIiwibmFtZSI6IkZpZ21hIiwidXJsIjoiIiwiaWNvbiI6Imh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9naC9ob21hcnItbGFicy9kYXNoYm9hcmQtaWNvbnMvc3ZnL2ZpZ21hLnN2ZyIsImNhdGVnb3J5SWQiOiIxNzUzMDQ0NDQ2NzU1In1dfSx7ImlkIjoiMTc1MzA0NDY2ODAxMiIsIm5hbWUiOiJNZWRpYSIsIndlYnNpdGVzIjpbeyJpZCI6IjE3NTMwNDQ3MDYwMjQiLCJuYW1lIjoiTmV0ZmxpeCIsInVybCI6Imh0dHA6Ly90di5oYXZlbi8iLCJpY29uIjoiaHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2hvbWFyci1sYWJzL2Rhc2hib2FyZC1pY29ucy9zdmcvbmV0ZmxpeC5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzUzMDQ0NzQwOTg2IiwibmFtZSI6IlByaW1lIiwidXJsIjoiaHR0cDovL29tdi5oYXZlbiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9wcmltZS12aWRlby1hbHQtZGFyay5zdmciLCJjYXRlZ29yeUlkIjoiMTc1MzA0NDY2ODAxMiJ9LHsiaWQiOiIxNzgzNzI4Nzg0MjU2IiwibmFtZSI6IkhCTyIsInVybCI6IiIsImljb24iOiJodHRwczovL2Nkbi5qc2RlbGl2ci5uZXQvZ2gvaG9tYXJyLWxhYnMvZGFzaGJvYXJkLWljb25zL3N2Zy9oYm8tbGlnaHQuc3ZnIiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc1MzA0NDgyODg0MCIsIm5hbWUiOiJUcmFrdCIsInVybCI6Imh0dHBzOi8vYXBwLnRyYWt0LnR2LyIsImljb24iOiJodHRwczovL3d3dy5nb29nbGUuY29tL3MyL2Zhdmljb25zP2RvbWFpbj1hcHAudHJha3QudHYmc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifSx7ImlkIjoiMTc4MzcyODcyOTQyNCIsIm5hbWUiOiJMZXR0ZXJib3hkIiwidXJsIjoiaHR0cHM6Ly9sZXR0ZXJib3hkLmNvbS8iLCJpY29uIjoiaHR0cHM6Ly93d3cuZ29vZ2xlLmNvbS9zMi9mYXZpY29ucz9kb21haW49bGV0dGVyYm94ZC5jb20mc3o9MTI4IiwiY2F0ZWdvcnlJZCI6IjE3NTMwNDQ2NjgwMTIifV19XQ==",
|
||||||
|
"config": "eyJ0aXRsZSI6IiIsImN1cnJlbnRXYWxscGFwZXJzIjpbIkFic3RyYWN0IFJlZCJdLCJ3YWxscGFwZXJGcmVxdWVuY3kiOiIxZCIsIndhbGxwYXBlckJsdXIiOjAsIndhbGxwYXBlckJyaWdodG5lc3MiOjEwOCwid2FsbHBhcGVyT3BhY2l0eSI6MTAwLCJ0aXRsZVNpemUiOiJtZWRpdW0iLCJhbGlnbm1lbnQiOiJtaWRkbGUiLCJob3Jpem9udGFsQWxpZ25tZW50IjoibWlkZGxlIiwidGlsZVNpemUiOiJzbWFsbCIsImNsb2NrIjp7ImVuYWJsZWQiOnRydWUsInNpemUiOiJ0aW55IiwiZm9udCI6Im1vbm9zcGFjZSIsImZvcm1hdCI6Img6bW0gQSJ9LCJzZXJ2ZXJXaWRnZXQiOnsiZW5hYmxlZCI6dHJ1ZSwicGluZ0ZyZXF1ZW5jeSI6MTUsInNlcnZlcnMiOlt7ImlkIjoiMTc4MzcyOTE4MzU1MyIsIm5hbWUiOiJBZEd1YXJkIiwiYWRkcmVzcyI6Imh0dHBzOi8vZ29vZ2xlLmNvbSJ9LHsiaWQiOiIxNzgzNzI5MjI3NDQyIiwibmFtZSI6IlByb3htb3giLCJhZGRyZXNzIjoiaHR0cHM6Ly9nb29nbGUuY29tIn1dfX0=",
|
||||||
|
"userWallpapers": "W3sibmFtZSI6ImRhcmstYWJzdHJhY3QtMjU2MHgxNDQwLWNvbnRlbXBvcmFyeS1kZXNpZ24tc2xlZWstbGluZXMtMjY0MjYuanBnIn1d",
|
||||||
|
"wallpaperState": "eyJsYXN0V2FsbHBhcGVyQ2hhbmdlIjoiMjA0Ni0wNy0xMVQwMDoyMzozMS4zODdaIiwiY3VycmVudEluZGV4IjowfQ=="
|
||||||
|
}
|
||||||
1
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||