36 Commits

Author SHA1 Message Date
254d8e26b6 Merge pull request 'feat/visual-rework' (#2) from feat/visual-rework into main
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 44s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m29s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 6s
Build and Release / build (push) Successful in 1m9s
Build and Release / virus-total-check (push) Successful in 1m24s
Build and Release / release (push) Successful in 8s
Build and Release / Build Vision Start Image (push) Successful in 1m3s
Build and Release / Deploy Vision Start (production) (push) Successful in 2s
Reviewed-on: #2
2026-07-04 18:03:17 +00:00
c59b7edd82 fixes and improvements 2026-07-04 15:01:14 -03:00
52d954d9ca Merge branch 'main' into feat/visual-rework 2026-07-03 14:59:57 -03:00
f35ea01c71 fixing issue with website ordering
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m4s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
2026-07-03 14:59:43 -03:00
1dfbe74280 wip: visual re-work 2026-07-03 14:56:43 -03:00
1580187689 big performance changes
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 2s
2026-07-03 14:28:26 -03:00
83dcf65069 adding package build to pipeline
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 43s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m30s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 12s
2026-07-03 14:12:05 -03:00
ddf2f2a416 fixing wallpaper
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 31s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m54s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 1m10s
2026-07-02 19:22:19 -03:00
c397e77c01 adjusting agents docs 2026-07-02 19:22:09 -03:00
85b239f540 updating readme
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 6s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m1s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
2026-03-21 13:09:45 -03:00
7efdd17534 refactoring configuration modal 2026-03-21 12:58:21 -03:00
b1957f2c19 adding import/export for configuration
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 46s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m22s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 12s
2026-03-21 12:44:23 -03:00
efc9e5c3dd updating virustotal to v3 endpoints
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release / build (push) Successful in 38s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m15s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / virus-total-check (push) Successful in 49s
Build and Release / release (push) Successful in 5s
Build and Release / Build Vision Start Image (push) Successful in 1m1s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
2026-03-21 00:59:49 -03:00
65c6946e7f adjusting intervals
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release / build (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m2s
Build and Release / virus-total-check (push) Failing after 37s
Build and Release / release (push) Has been skipped
Build and Release / Build Vision Start Image (push) Has been skipped
Build and Release / Deploy Vision Start (production) (push) Has been skipped
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
2026-03-21 00:48:25 -03:00
3129fa6531 Merge branch 'main' of git.ivanch.me:ivanch/vision-start
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 7s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m3s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 9s
Build and Release / build (push) Successful in 16s
Build and Release / virus-total-check (push) Failing after 1m7s
Build and Release / release (push) Has been skipped
Build and Release / Build Vision Start Image (push) Has been skipped
Build and Release / Deploy Vision Start (production) (push) Has been skipped
2026-03-21 00:38:04 -03:00
82da27cf8d fixing release pipeline (final ;)) 2026-03-21 00:38:00 -03:00
c4dce04d42 migrate to Preact and add animations (#1)
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m1s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
- Replace React 19 with Preact via @preact/preset-vite (zero component changes needed — Vite aliases react → preact/compat at build time)
- Add custom iOS easing curves (ease-ios, ease-spring) via Tailwind @theme
- Update all transitions to use iOS-standard 200ms durations and spring/decel easing
- Add active:scale press feedback on tiles, buttons, and toggles
- Toggle knob now uses spring easing for a satisfying snap

Reviewed-on: #1
Co-authored-by: Jose Henrique <jose.henrique.ivan@gmail.com>
Co-committed-by: Jose Henrique <jose.henrique.ivan@gmail.com>
2026-03-21 03:32:01 +00:00
c2b3356022 trying ubuntu-amd64
All checks were successful
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Successful in 2m28s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 8s
2026-03-21 00:25:25 -03:00
d067e0b95c try
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 3m29s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-21 00:16:32 -03:00
aec7a331c6 maybe now
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 1m32s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:49:30 -03:00
0d636ab680 another try
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 1m32s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:46:29 -03:00
69c6c6fe09 maybe fixing pipeline docker
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 2m11s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:37:41 -03:00
fd552c48cd fixing pipelines
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 11s
Build and Release to Staging / Build Vision Start Image (push) Failing after 1m35s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been skipped
2026-03-20 23:31:30 -03:00
95b7be5219 fixing both pipelines
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 12s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been cancelled
Build and Release to Staging / Build Vision Start Image (push) Has been cancelled
2026-03-20 23:27:41 -03:00
b8e1468a46 adding dockerfile and pipelines
Some checks failed
Build and Release to Staging / Build Vision Start (push) Successful in 50s
Build and Release to Staging / Deploy Vision Start (staging) (push) Has been cancelled
Build and Release to Staging / Build Vision Start Image (push) Has been cancelled
2026-03-20 23:26:18 -03:00
199d92f733 updating .gitignore 2026-03-20 23:14:32 -03:00
d805531369 changing lf 2026-03-20 23:11:52 -03:00
8cc17a5a17 tidy repo
All checks were successful
Build and Release / build (push) Successful in 1m1s
2025-09-20 20:42:46 -03:00
d4c1884471 fixes
All checks were successful
Build and Release / build (push) Successful in 15s
Build and Release / virus-total-check (push) Successful in 37s
Build and Release / release (push) Successful in 7s
2025-08-26 22:11:42 -03:00
377be6e8f6 removing subtitle 2025-08-26 22:00:21 -03:00
c60cb24dd4 updating readme
All checks were successful
Build and Release / build (push) Successful in 54s
2025-08-01 23:26:29 -03:00
2f3949c2e3 changes to the overall structure + improving wallpaper handling 2025-08-01 23:23:50 -03:00
9b818b05f9 I have to use some actions from v3 apparently
All checks were successful
Build and Release / build (push) Successful in 26s
Build and Release / virus-total-check (push) Successful in 51s
Build and Release / release (push) Successful in 5s
2025-07-30 18:14:21 -03:00
008d2321e5 adding virus total check
Some checks failed
Build and Release / build (push) Failing after 26s
Build and Release / virus-total-check (push) Has been skipped
Build and Release / release (push) Has been skipped
2025-07-30 18:08:27 -03:00
3aff7ffed6 whoopsie
All checks were successful
Build and Release / build (push) Successful in 12s
Build and Release / release (push) Successful in 12s
2025-07-30 17:40:30 -03:00
9e80818fc5 redoing pipelines
All checks were successful
Build and Release / build (push) Successful in 11s
Build and Release / release (push) Has been skipped
2025-07-30 17:40:02 -03:00
53 changed files with 3393 additions and 1471 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.env
.DS_Store
.claude/

View File

@@ -1,16 +1,22 @@
name: Build and Release name: Build and Release to Staging
on: on:
push: push:
branches: branches:
- main - main
tags: workflow_dispatch:
- v*
env:
REGISTRY_HOST: git.ivanch.me
REGISTRY_USERNAME: ivanch
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
IMAGE_TAG: staging
jobs: jobs:
build: build:
name: Build Vision Start
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-amd64
steps: steps:
- name: Check out repository code - name: Check out repository code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,28 +25,58 @@ jobs:
- name: Run build - name: Run build
run: npm run build run: npm run build
release: - name: Package dist as zip
if: gitea.event_name == 'tag' run: |
runs-on: ubuntu-latest cd dist
steps: zip -r ../vision-start-build.zip .
- name: Check out repository code
uses: actions/checkout@v4 - name: Upload build artifact
- name: Setup zip uses: actions/upload-artifact@v3
run: sudo apt-get install zip -y
- name: Install JS dependencies
run: npm install
- name: Run build
run: npm run build
- name: Prepare release
run: |
bash scripts/prepare_release.sh
mv dist vision-start/
mv manifest.json vision-start/
- name: Create zip archive
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
- name: Release zip
uses: akkuman/gitea-release-action@v1
with: with:
name: ${{ gitea.ref_name }} name: vision-start-build
tag_name: ${{ gitea.ref_name }} path: vision-start-build.zip
files: vision-start-${{ gitea.ref_name }}.zip retention-days: 30
build_vision_start:
name: Build Vision Start Image
runs-on: ubuntu-amd64
needs: build
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Log in to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
| docker login "${{ env.REGISTRY_HOST }}" \
-u "${{ env.REGISTRY_USERNAME }}" \
--password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Multi-Arch Image
uses: docker/build-push-action@v6
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
deploy_vision_start:
name: Deploy Vision Start (staging)
runs-on: ubuntu-amd64
needs: build_vision_start
steps:
- name: Recreate Container
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
cd ${{ secrets.STAGING_DIR }}
docker compose pull
docker compose up -d --force-recreate

View File

@@ -0,0 +1,138 @@
name: Build and Release
on:
push:
tags:
- v*
env:
REGISTRY_HOST: git.ivanch.me
REGISTRY_USERNAME: ivanch
IMAGE_NAME: ${{ env.REGISTRY_HOST }}/ivanch/vision-start
IMAGE_TAG: latest
jobs:
build:
runs-on: ubuntu-latest
outputs:
zip-file: vision-start-${{ gitea.ref_name }}.zip
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup required tools
run: sudo apt-get install zip jq curl -y
- name: Install JS dependencies
run: npm install
- name: Run build
run: npm run build
- name: Prepare release
run: |
bash scripts/prepare_release.sh
mv dist vision-start/
mv manifest.json vision-start/
- name: Create zip archive
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: release-zip
path: vision-start-${{ gitea.ref_name }}.zip
virus-total-check:
runs-on: ubuntu-latest
needs: build
outputs:
analysis-url: ${{ steps.vt-check.outputs.analysis-url }}
detection-ratio: ${{ steps.vt-check.outputs.detection-ratio }}
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup required tools
run: sudo apt-get install jq curl -y
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: release-zip
- name: Run VirusTotal check
id: vt-check
env:
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
run: |
# Run the VirusTotal check script and capture output in real-time
set -o pipefail
bash scripts/check_virustotal.sh 2>&1 | tee vt_output.txt
# Extract analysis URL and detection ratio from output
ANALYSIS_URL=$(grep "Analysis URL:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
DETECTION_RATIO=$(grep "Detection ratio:" vt_output.txt | cut -d' ' -f3- || echo "Not available")
# Set outputs for next job
echo "analysis-url=$ANALYSIS_URL" >> $GITEA_OUTPUT
echo "detection-ratio=$DETECTION_RATIO" >> $GITEA_OUTPUT
release:
runs-on: ubuntu-latest
needs: [build, virus-total-check]
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Download artifact
uses: actions/download-artifact@v3
with:
name: release-zip
- name: Release zip
uses: akkuman/gitea-release-action@v1
with:
body: |
This is the release for version ${{ gitea.ref_name }}.
**Virus Total Analysis URL:** ${{ needs.virus-total-check.outputs.analysis-url }}
**Virus Total Detection Ratio:** ${{ needs.virus-total-check.outputs.detection-ratio }}
name: ${{ gitea.ref_name }}
tag_name: ${{ gitea.ref_name }}
files: vision-start-${{ gitea.ref_name }}.zip
build_vision_start:
name: Build Vision Start Image
runs-on: ubuntu-amd64
needs: [build, virus-total-check]
steps:
- name: Check out repository
uses: actions/checkout@v2
- name: Log in to Container Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" \
| docker login "${{ env.REGISTRY_HOST }}" \
-u "${{ env.REGISTRY_USERNAME }}" \
--password-stdin
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Multi-Arch Image
uses: docker/build-push-action@v6
with:
push: true
context: .
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
deploy_vision_start:
name: Deploy Vision Start (production)
runs-on: ubuntu-amd64
needs: build_vision_start
steps:
- name: Recreate Container
uses: appleboy/ssh-action@v0.1.7
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY }}
port: ${{ secrets.PORT }}
script: |
cd ${{ secrets.PROD_DIR }}
docker compose pull
docker compose up -d --force-recreate

1
.gitignore vendored Executable file → Normal file
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.claude/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

116
AGENTS.md Normal file
View File

@@ -0,0 +1,116 @@
# AGENTS.md
Guidance for AI agents (and humans) working on Vision Start. Read before making changes.
---
## 0. First rule: keep `project-context.md` alive
- **Always read `project-context.md` first.** It is the canonical map of the project: structure, features, data model, flow, build/release. Treat it as required reading before any non-trivial change.
- **Always keep it updated.** Whenever you add, remove, rename, or materially change files, modules, features, storage keys, config fields, build/release steps, or workflows, update the relevant section of `project-context.md` in the same change set. If your work touches the structure tree, the data model, the storage layout, or the quick-lookup table, those sections must reflect the new state.
- Keep it a general overview, not a coding-style guide — that distinction lives here in `AGENTS.md`.
---
## 1. Styling guidance
The design language is **soft liquid glass**: lightly translucent matte surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan accents, and iOS-like easing.
### Surface recipe (use consistently)
- **Tiles / floating controls / light surfaces:** `liquid-surface` plus `liquid-control` / `liquid-tile` / `liquid-focus` as appropriate.
- **Cards / modals / panels:** `liquid-panel liquid-modal-card rounded-3xl` for centered modals; `liquid-drawer` for the settings drawer.
- **Inputs:** `liquid-input p-3`; ranges use `liquid-range`.
- **Buttons:** `liquid-button` plus one of `liquid-button-primary`, `liquid-button-success`, `liquid-button-secondary`, or `liquid-button-danger`.
- **Add/edit surfaces:** add tiles use `liquid-ghost-tile`; tile edit controls use `liquid-edit-toolbar` and `liquid-edit-action`.
- **Full-screen modal overlay:** `liquid-modal-backdrop`.
- Keep the glass effect subtle: avoid high-opacity white highlights, heavy blur, strong saturation, or oversized glow unless a specific component needs emphasis.
### Color tokens
- Accent / focus / selection: cyan via `liquid-focus`, `cyan-400`, `cyan-200`, and `liquid-button-primary`
- Confirm/Save: `liquid-button-success`
- Cancel/secondary: `liquid-button-secondary`
- Destructive: `liquid-button-danger` or red text on `liquid-edit-action`
- Tertiary (export/import etc.): `liquid-button-secondary`
- Status: online `bg-green-400 text-green-400`, offline `bg-red-400 text-red-400`, pending `bg-slate-400 text-slate-400`
- Text: primary `text-white`, secondary `text-slate-300`/`text-slate-400`, muted `text-white/50` (in-app hover states)
### Motion
- Use the custom easing tokens defined in `index.css`: `ease-ios` (default), `ease-spring` (toggle knobs, drawer slides), and `ease-liquid` (tile lift and wallpaper transitions).
- Standard durations: `duration-150` (buttons), `duration-200` (tiles, toggles, icons), and `duration-300` (drawers/modals).
- Prefer the shared liquid classes for hover/press motion. Add custom transforms only when the shared classes do not cover the interaction.
- Keep `prefers-reduced-motion` behavior intact when adding animations.
### Tailwind specifics
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`.
- `tailwind.config.js` has a **safelist** of `w-[Npx]/h-[Npx]` classes because tile/icon sizes are constructed dynamically. If you add new pixel-size classes that are generated at runtime (not literally present in source), add them to the safelist or they will be purged.
- Prefer static classes; only build dynamic class strings when unavoidable and then safelist them.
### Components with established conventions to reuse
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
- `ToggleSwitch` (`components/ToggleSwitch.tsx`) for boolean toggles — **always use it** instead of checkboxes.
- Modal pattern: `liquid-modal-backdrop` + centered `liquid-panel liquid-modal-card rounded-3xl`, close on overlay click via `handleOverlayClick` (`if (e.target === e.currentTarget) onClose()`).
- New editors/settings go in `components/configuration/` as a `*Tab.tsx` and are wired into `ConfigurationModal.tsx`.
- New options' size presets follow the existing scale: `tiny | small | medium | large` (see `Header.tsx`, `WebsiteTile.tsx`).
---
## 2. New feature guidance
A "feature" in this project typically means: a new widget, a new settings option, or a new bit of persisted state. When adding one, follow this checklist.
### A. Types & defaults (in order)
1. **`types.ts`** — add new fields to the relevant interface (usually `Config`, sometimes a nested one like `clock`/`serverWidget`). Create a nested object when a feature has 2+ sub-options (mirror the `clock`/`serverWidget` pattern).
2. **`components/services/ConfigurationService.ts` `DEFAULT_CONFIG`** — add the new field with a sane default so existing users (with a previously-saved `Config`) get it via the `{ ...DEFAULT_CONFIG, ...parsed }` merge on load.
3. Any new standalone collections (not inside `Config`) get their own storage key/helper in `ConfigurationService` (see `userWallpapers`, `wallpaperState` for the pattern).
### B. Settings UI (if the feature is user-configurable)
1. Create `components/configuration/<Feature>Tab.tsx` and add its entry to the `tabs` array in `components/ConfigurationModal.tsx`.
2. Receive `config` and an `onChange(updates: Partial<Config>)` callback; emit partial updates — `App.tsx`'s `handleConfigChange` merges shallowly, so update nested objects as whole units (e.g. `{ clock: { ...config.clock, ...updates } }`).
3. Use `Dropdown` / `ToggleSwitch` / range inputs per the styling section above.
### C. Rendering & wiring
4. Render the feature in `App.tsx` (or a dedicated component imported by `App.tsx`), gated by its `config.<feature>.enabled` flag when it can be turned off.
5. Mark up new modal/edit surfaces to match existing modals (backdrop click closes, glass card styling).
### D. Persistence — configs must be properly saved
6. `App.tsx` already persists `config` via `ConfigurationService.saveConfig(config)` in a `useEffect`. **Any state added to `Config` is persisted automatically** — do not introduce parallel ad-hoc `localStorage` writes for config fields.
7. Standalone collections (not in `Config`) need explicit save calls — mirror `userWallpapers`: load on mount, save on every mutation, never keep stale copies.
8. Never write to `chrome.storage.local` directly outside `components/utils/StorageLocalManager.ts`. Always check `checkChromeStorageLocalAvailable()` first and gracefully degrade (the app must still run as a plain web page). Throw informative errors when the storage is required but missing.
### E. Export / import must keep working
9. If you add a new `localStorage` key (other than config fields, which are exported/imported automatically), add it to `REQUIRED_LOCAL_STORAGE_KEYS` in `ConfigurationService.ts` so it is included in exports and restored on imports.
10. After adding fields to `Config`, double check the import path: `importConfig` writes each restored key to `localStorage` and returns `{ config, userWallpapers }`. New config sub-objects flow through automatically because they're inside `config`.
### F. Defaults & migrations
11. Default values must be chosen so a fresh install and an upgrade from an older `Config` both behave sensibly (the load path merges onto `DEFAULT_CONFIG`).
12. If a new field changes behavior in a way that existing users' stored data should be preserved differently, handle the migration inside `loadConfig` (and consider bumping the export `version` field in `exportConfig` if the shape changes non-additively).
### G. Style of new tiles/widgets
13. A new widget should follow the existing widget shape: optional/gated by `config.<feature>.enabled`, positioned with a liquid glass container, and use the surface/duration/easing tokens above.
14. New "editable" tiles/content reuse the `isEditing` mode and the edit/move/delete affordances pattern from `WebsiteTile.tsx` and `CategoryGroup.tsx`.
---
## 3. General engineering rules
- **Language:** TypeScript strict mode (`tsconfig.json` enforces `strict`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`). Do not loosen these.
- **Runtime note:** Preact is the actual runtime (`@preact/preset-vite`), but types and imports use `react`/`@types/react`. Do not introduce React-only APIs without verifying Preact compatibility.
- **Path alias:** `@/*` maps to the project root (see `tsconfig.json` `paths`). Source files currently use relative imports (`./`, `../`) — match the surrounding file.
- **No comments** in committed code unless explicitly requested.
- **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. The only npm scripts are `dev`, `build`, `preview`.
- **No lint/typecheck script is configured.** Verify TS correctness manually (e.g. `npx tsc --noEmit`), and confirm `npm run build` succeeds before considering a task complete.
- **Don't touch `public/icon-metadata.json`** — it's gitignored and fetched at release-build time by `scripts/prepare_release.sh`. Don't commit a local copy.
- **Don't reintroduce dead code:** `components/EditModal.tsx` imports `lucide-react` and `./IconPicker`, neither of which is a dependency. It is not wired into the app. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, and add the icon picker inline (as in `WebsiteEditModal.tsx`) rather than reviving `IconPicker`.
- **External asset URLs:** any new icons/css/JS pulled from CDNs (e.g. `cdn.jsdelivr.net`) must work offline-or-not without crashing — always provide a fallback (see how `iconService.ts` falls back to Google's S2 favicon service).
- **Manifest & extension constraints:** the extension uses Manifest V3 with `script-src 'self'` CSP and only the `storage` permission. Don't introduce inline scripts/eval, and avoid requesting new permissions unless essential — added permissions can disable updates on installed extensions.
- **Commits:** never commit unless explicitly asked. Don't touch secrets, don't edit `.gitignore`/`.env.local`/`.claude/`, don't run `git config`.
---
## 4. When in doubt
- Read `project-context.md`.
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
- Favor the established liquid utilities (`liquid-surface`, `liquid-panel`, `liquid-input`, `liquid-button`, `liquid-focus`) over ad-hoc glass class strings.
- Surface failing assumptions (CORS, missing chrome.storage, larger-than-4MB wallpapers) with clear errors, matching the style of `StorageLocalManager.ts` and `iconService.ts`.
- Update `project-context.md` after your change.

337
App.tsx Executable file → Normal file
View File

@@ -1,40 +1,42 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import ConfigurationModal from './components/ConfigurationModal';
import ServerWidget from './components/ServerWidget'; import ServerWidget from './components/ServerWidget';
import { DEFAULT_CATEGORIES } from './constants'; import { DEFAULT_CATEGORIES } from './constants';
import { Category, Website, Wallpaper, Config } from './types'; import { Category, Website, Config } from './types';
import WebsiteEditModal from './components/WebsiteEditModal';
import CategoryEditModal from './components/CategoryEditModal';
import Header from './components/layout/Header'; import Header from './components/layout/Header';
import EditButton from './components/layout/EditButton'; import EditButton from './components/layout/EditButton';
import ConfigurationButton from './components/layout/ConfigurationButton'; import ConfigurationButton from './components/layout/ConfigurationButton';
import CategoryGroup from './components/layout/CategoryGroup'; import CategoryGroup from './components/layout/CategoryGroup';
import Wallpaper from './components/Wallpaper';
import { ConfigurationService } from './components/services/ConfigurationService';
import { baseWallpapers } from './components/utils/baseWallpapers'; const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
const defaultConfig: Config = { const getAlignmentClass = (alignment: string) => {
title: 'Vision Start', switch (alignment) {
subtitle: 'Your personal portal to the web.', case 'top':
backgroundUrls: ['https://i.imgur.com/C6ynAtX.jpeg'], return 'justify-start';
wallpaperFrequency: '1d', case 'middle':
wallpaperBlur: 0, return 'justify-center';
wallpaperBrightness: 100, case 'bottom':
wallpaperOpacity: 100, return 'justify-end';
titleSize: 'medium', default:
subtitleSize: 'medium', return 'justify-center';
alignment: 'middle', }
horizontalAlignment: 'middle', };
clock: {
enabled: true, const getHorizontalAlignmentClass = (alignment: string) => {
size: 'medium', switch (alignment) {
font: 'Helvetica', case 'left':
format: 'h:mm A', return 'justify-start';
}, case 'middle':
serverWidget: { return 'justify-center';
enabled: false, case 'right':
pingFrequency: 15, return 'justify-end';
servers: [], default:
}, return 'justify-center';
}
}; };
const App: React.FC = () => { const App: React.FC = () => {
@@ -55,91 +57,58 @@ const App: React.FC = () => {
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null); const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [config, setConfig] = useState<Config>(() => { const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig());
const [wallpaperVersion, setWallpaperVersion] = useState(0);
useEffect(() => {
ConfigurationService.saveConfig(config);
}, [config]);
useEffect(() => {
try { try {
const storedConfig = localStorage.getItem('config'); localStorage.setItem('categories', JSON.stringify(categories));
if (storedConfig) {
const parsedConfig = JSON.parse(storedConfig);
if (!parsedConfig.backgroundUrls) {
parsedConfig.backgroundUrls = [parsedConfig.backgroundUrl].filter(Boolean);
}
return { ...defaultConfig, ...parsedConfig };
}
} catch (error) { } catch (error) {
console.error('Error parsing config from localStorage', error); console.error('Error saving categories to localStorage', error);
} }
return { ...defaultConfig }; }, [categories]);
});
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>(() => {
const storedUserWallpapers = localStorage.getItem('userWallpapers');
return storedUserWallpapers ? JSON.parse(storedUserWallpapers) : [];
});
const [currentWallpaper, setCurrentWallpaper] = useState<string>('');
const allWallpapers = [...baseWallpapers, ...userWallpapers]; const handleSaveConfig = useCallback((newConfig: Config) => {
useEffect(() => {
const getFrequencyInMs = (frequency: string) => {
const value = parseInt(frequency.slice(0, -1));
const unit = frequency.slice(-1);
if (unit === 'h') return value * 60 * 60 * 1000;
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
return 24 * 60 * 60 * 1000; // Default to 1 day
};
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const lastChanged = wallpaperState.lastChanged ? new Date(wallpaperState.lastChanged).getTime() : 0;
const frequency = getFrequencyInMs(config.wallpaperFrequency);
const updateWallpaper = () => {
const availableWallpapers = allWallpapers.filter(w => config.backgroundUrls.includes(w.url || w.base64));
if (availableWallpapers.length > 0) {
const currentWallpaperFromState = allWallpapers.find(w => w.name === wallpaperState.current);
const currentIndex = currentWallpaperFromState ? availableWallpapers.findIndex(w => w.name === currentWallpaperFromState.name) : -1;
const nextIndex = (currentIndex + 1) % availableWallpapers.length;
const newWallpaper = availableWallpapers[nextIndex];
const newWallpaperUrl = newWallpaper.url || newWallpaper.base64;
setCurrentWallpaper(newWallpaperUrl || '');
localStorage.setItem('wallpaperState', JSON.stringify({ current: newWallpaper.name, lastChanged: new Date().toISOString() }));
} else {
setCurrentWallpaper('');
localStorage.removeItem('wallpaperState');
}
};
const currentWallpaperDetails = allWallpapers.find(w => w.name === wallpaperState.current);
const isCurrentWallpaperValid = currentWallpaperDetails && config.backgroundUrls.includes(currentWallpaperDetails.url || currentWallpaperDetails.base64 || '');
if (!isCurrentWallpaperValid || Date.now() - lastChanged > frequency) {
updateWallpaper();
} else if (currentWallpaperDetails) {
setCurrentWallpaper(currentWallpaperDetails.url || currentWallpaperDetails.base64 || '');
} else {
// Fallback for when there's no valid wallpaper state
updateWallpaper();
}
}, [config.backgroundUrls, config.wallpaperFrequency, allWallpapers]);
useEffect(() => {
localStorage.setItem('categories', JSON.stringify(categories));
localStorage.setItem('config', JSON.stringify(config));
}, [categories, config]);
const handleSaveConfig = (newConfig: any) => {
setConfig(newConfig); setConfig(newConfig);
setIsConfigModalOpen(false); setIsConfigModalOpen(false);
}; }, []);
const handleWallpaperChange = (newConfig: Partial<Config>) => { const handleWallpaperChange = useCallback((newConfig: Partial<Config>) => {
setConfig(prev => ({ ...prev, ...newConfig })); setConfig(prev => ({ ...prev, ...newConfig }));
}; }, []);
const handleSaveWebsite = (website: Partial<Website>) => { const handleNextWallpaper = useCallback(() => {
const names = config.currentWallpapers;
if (names.length === 0) return;
try {
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
const nextIndex = (safeCurrent + 1) % names.length;
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: nextIndex,
}),
);
} catch (error) {
console.error('Error advancing wallpaper state', error);
}
setWallpaperVersion(v => v + 1);
}, [config.currentWallpapers]);
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
if (editingWebsite) { if (editingWebsite) {
const idToUpdate = website.id ?? editingWebsite.id;
const newCategories = categories.map(category => ({ const newCategories = categories.map(category => ({
...category, ...category,
websites: category.websites.map(w => websites: category.websites.map(w =>
w.id === website.id ? { ...w, ...website } : w w.id === idToUpdate ? { ...w, ...website, id: idToUpdate } : w
), ),
})); }));
setCategories(newCategories); setCategories(newCategories);
@@ -160,9 +129,9 @@ const App: React.FC = () => {
setCategories(newCategories); setCategories(newCategories);
setAddingWebsite(null); setAddingWebsite(null);
} }
}; }, [editingWebsite, addingWebsite, categories]);
const handleSaveCategory = (name: string) => { const handleSaveCategory = useCallback((name: string) => {
if (editingCategory) { if (editingCategory) {
const newCategories = categories.map(category => const newCategories = categories.map(category =>
category.id === editingCategory.id ? { ...category, name } : category category.id === editingCategory.id ? { ...category, name } : category
@@ -178,9 +147,9 @@ const App: React.FC = () => {
} }
setEditingCategory(null); setEditingCategory(null);
setIsCategoryModalOpen(false); setIsCategoryModalOpen(false);
}; }, [editingCategory, categories]);
const handleDeleteWebsite = () => { const handleDeleteWebsite = useCallback(() => {
if (!editingWebsite) return; if (!editingWebsite) return;
const newCategories = categories.map(category => ({ const newCategories = categories.map(category => ({
@@ -189,90 +158,56 @@ const App: React.FC = () => {
})); }));
setCategories(newCategories); setCategories(newCategories);
setEditingWebsite(null); setEditingWebsite(null);
}; }, [editingWebsite, categories]);
const handleDeleteCategory = () => { const handleDeleteCategory = useCallback(() => {
if (!editingCategory) return; if (!editingCategory) return;
const newCategories = categories.filter(c => c.id !== editingCategory.id); const newCategories = categories.filter(c => c.id !== editingCategory.id);
setCategories(newCategories); setCategories(newCategories);
setEditingCategory(null); setEditingCategory(null);
setIsCategoryModalOpen(false); setIsCategoryModalOpen(false);
}; }, [editingCategory, categories]);
const handleMoveWebsite = (website: Website, direction: 'left' | 'right') => { const handleMoveWebsite = useCallback((website: Website, direction: 'left' | 'right') => {
const categoryIndex = categories.findIndex(c => c.id === website.categoryId); const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
if (categoryIndex === -1) return; if (categoryIndex === -1) return;
const category = categories[categoryIndex]; const category = categories[categoryIndex];
const websiteIndex = category.websites.findIndex(w => w.id === website.id); const websiteIndex = category.websites.findIndex(w => w.id === website.id);
if (websiteIndex === -1) return; if (websiteIndex === -1) return;
const newCategories = [...categories]; const targetIndex = direction === 'left' ? websiteIndex - 1 : websiteIndex + 1;
if (targetIndex < 0 || targetIndex >= category.websites.length) return;
const newWebsites = [...category.websites]; const newWebsites = [...category.websites];
const [movedWebsite] = newWebsites.splice(websiteIndex, 1); const [movedWebsite] = newWebsites.splice(websiteIndex, 1);
newWebsites.splice(targetIndex, 0, movedWebsite);
if (direction === 'left') { const newCategories = [...categories];
const newCategoryIndex = (categoryIndex - 1 + categories.length) % categories.length; newCategories[categoryIndex] = { ...category, websites: newWebsites };
newCategories[categoryIndex] = { ...category, websites: newWebsites };
const destCategory = newCategories[newCategoryIndex];
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
} else {
const newCategoryIndex = (categoryIndex + 1) % categories.length;
newCategories[categoryIndex] = { ...category, websites: newWebsites };
const destCategory = newCategories[newCategoryIndex];
const destWebsites = [...destCategory.websites, { ...movedWebsite, categoryId: destCategory.id }];
newCategories[newCategoryIndex] = { ...destCategory, websites: destWebsites };
}
setCategories(newCategories); setCategories(newCategories);
}; }, [categories]);
const getAlignmentClass = (alignment: string) => {
switch (alignment) {
case 'top':
return 'justify-start';
case 'middle':
return 'justify-center';
case 'bottom':
return 'justify-end';
default:
return 'justify-center';
}
};
const getHorizontalAlignmentClass = (alignment: string) => {
switch (alignment) {
case 'left':
return 'justify-start';
case 'middle':
return 'justify-center';
case 'right':
return 'justify-end';
default:
return 'justify-center';
}
};
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`}
> >
<div <Wallpaper
className="fixed inset-0 w-full h-full bg-cover bg-center bg-fixed -z-10" wallpaperNames={config.currentWallpapers}
style={{ blur={config.wallpaperBlur}
backgroundImage: `url('${currentWallpaper}')`, brightness={config.wallpaperBrightness}
filter: `blur(${config.wallpaperBlur}px) brightness(${config.wallpaperBrightness}%)`, opacity={config.wallpaperOpacity}
opacity: `${config.wallpaperOpacity}%`, wallpaperFrequency={config.wallpaperFrequency}
}} wallpaperVersion={wallpaperVersion}
></div> />
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} /> <EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} /> <ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
<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}
@@ -284,66 +219,72 @@ const App: React.FC = () => {
setEditingWebsite={setEditingWebsite} setEditingWebsite={setEditingWebsite}
handleMoveWebsite={handleMoveWebsite} handleMoveWebsite={handleMoveWebsite}
getHorizontalAlignmentClass={getHorizontalAlignmentClass} getHorizontalAlignmentClass={getHorizontalAlignmentClass}
config={config} horizontalAlignment={config.horizontalAlignment}
tileSize={config.tileSize}
/> />
))} ))}
{isEditing && ( {isEditing && (
<div className={`flex justify-center transition-all duration-300 ease-in-out transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}> <div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
<button <button
onClick={() => { onClick={() => {
setEditingCategory(null); setEditingCategory(null);
setIsCategoryModalOpen(true); setIsCategoryModalOpen(true);
}} }}
className="text-white/50 hover:text-white transition-colors" className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
aria-label="Add category"
> >
<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>
)} )}
</div> </div>
{config.serverWidget.enabled && ( {config.serverWidget.enabled && <ServerWidget config={config} />}
<div className="absolute bottom-4 right-4">
<ServerWidget config={config} />
</div>
)}
{(editingWebsite || addingWebsite) && ( {(editingWebsite || addingWebsite) && (
<WebsiteEditModal <Suspense fallback={null}>
website={editingWebsite || undefined} <WebsiteEditModal
edit={!!editingWebsite} website={editingWebsite || undefined}
onClose={() => { edit={!!editingWebsite}
setEditingWebsite(null); onClose={() => {
setAddingWebsite(null); setEditingWebsite(null);
}} setAddingWebsite(null);
onSave={handleSaveWebsite} }}
onDelete={handleDeleteWebsite} onSave={handleSaveWebsite}
/> onDelete={handleDeleteWebsite}
/>
</Suspense>
)} )}
{isCategoryModalOpen && ( {isCategoryModalOpen && (
<CategoryEditModal <Suspense fallback={null}>
category={editingCategory || undefined} <CategoryEditModal
edit={!!editingCategory} category={editingCategory || undefined}
onClose={() => { edit={!!editingCategory}
setEditingCategory(null); onClose={() => {
setIsCategoryModalOpen(false); setEditingCategory(null);
}} setIsCategoryModalOpen(false);
onSave={handleSaveCategory} }}
onDelete={handleDeleteCategory} onSave={handleSaveCategory}
/> onDelete={handleDeleteCategory}
/>
</Suspense>
)} )}
{isConfigModalOpen && ( {isConfigModalOpen && (
<ConfigurationModal <Suspense fallback={null}>
currentConfig={config} <ConfigurationModal
onClose={() => setIsConfigModalOpen(false)} currentConfig={config}
onSave={handleSaveConfig} onClose={() => setIsConfigModalOpen(false)}
onWallpaperChange={handleWallpaperChange} onSave={handleSaveConfig}
/> onWallpaperChange={handleWallpaperChange}
onNextWallpaper={handleNextWallpaper}
/>
</Suspense>
)} )}
</main> </main>
); );

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN sh scripts/prepare_release.sh
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY manifest.json /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/gzip.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,39 +0,0 @@
# Vision Startpage Project
## Overview
This project is a highly customizable and stylish startpage built with React. The goal is to create a visually appealing and functional dashboard that serves as a user's entry point to the web.
## Key Features & Design Principles
* **Technology Stack:** The project is built using React and TypeScript.
* **Aesthetics:** The user interface should have a modern, "glassy" or "frosted glass" look (neumorphism/glassmorphism). This involves using transparency, blur effects, and subtle shadows to create a sense of depth.
* **Typography:** Specific font families and types will be used to maintain a consistent and elegant design.
* **Modals:** All modals in the application should follow a specific and consistent design language, contributing to the overall user experience.
* **Production Quality Code:** All code must be written to production standards, with a strong emphasis on readability, maintainability, and performance.
* **Creative & Beautiful Code:** Code should not only be functional but also well-structured, elegant, and creative.
* **Dropdown Component:** A reusable dropdown component (`components/Dropdown.tsx`) has been created for consistent styling and functionality across the application. It features a dark, glassy look with a custom arrow icon.
**Usage Example:**
```typescript jsx
import Dropdown from './components/Dropdown';
// ... inside a React component
<Dropdown
options={[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
]}
value={selectedValue}
onChange={handleSelectChange}
name="myDropdown"
/>
```
## Development Guidelines
* Follow the existing code style and conventions.
* Ensure all new components and features align with the established design principles.
* Write clean, commented, and reusable code.
* DO NOT run `npm run dev`, and instead, run `npm run build`.

56
README.md Executable file → Normal file
View File

@@ -1,31 +1,44 @@
# Vision Start <div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
#### A glassmorphism-looking like, modern and customizable startpage built with React. Vision Start
</div>
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
A light liquid-glass, modern and customizable startpage built with React.
</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>
## Screenshots ## Screenshots
![Vision Start with the Dark background](screenshots/dark-page.png) ![Vision Start home](screenshots/home.png)
![Editing page with Abstract Red background](screenshots/editing-abstract-red.png) ![Vision Start editing](screenshots/editing.png)
![Configuration Tab opened](screenshots/configuration-abstract-red.png) ![Vision Start configuration](screenshots/configuration.png)
## Backgrounds ## Installing
It comes with a selection of some nice pre-defined backgrounds. You can also upload up to one image to it. Vision Start is not yet available on Chrome Web Store, but it can be installed manually:
1. Go to https://git.ivanch.me/ivanch/vision-start/releases/latest
* **Abstract** 2. Download the latest `vision-start-[version].zip` file
* **Abstract Red** 3. Extract the zip file, you will have a `vision-start` folder
* **Beach** 4. Go to chrome://extensions/
* **Dark** 5. Enable "Developer mode" in the top right corner
* **Mountain** 6. Click on "Load unpacked" and select the `vision-start` folder you extracted in step 3
* **Waves** 7. The extension should now be installed! Just open a new tab to see it in action.
## Features ## Features
* **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.
* **Future**: a long to do list :( * **Settings:** A settings page to configure the startpage, with export/import functionality.
## Backgrounds
It comes with a selection of some nice pre-defined backgrounds: **Abstract**, **Abstract Red**, **Beach**, **Dark**, **Mountain**, **Waves**.
You can also upload your own images on it (or fetch it from the web).
## Running Locally ## Running Locally
@@ -49,10 +62,10 @@ npm run dev
* [x] Multiple Wallpapers * [x] Multiple Wallpapers
* [x] Remake icons * [x] Remake icons
* [] Increase offline compatibility (might not be possible) * [/] Increase offline compatibility (might not be possible)
* Use chrome.storage.local for user wallpapers -- this one is - [x] Use chrome.storage.local for user wallpapers -- this one is
* Use chrome.storage.local for some logos -- a bit hard - [ ] Use chrome.storage.local for some logos -- a bit hard
* Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local - Some logos have CORS enabled, we can add `"<all_urls>"` to the manifest.json file and cache them on storage local
* Dynamic Weather Widget * Dynamic Weather Widget
* A box with information about the current weather, with manual entry on the location * A box with information about the current weather, with manual entry on the location
* Display current temperature, weather condition (e.g., "Sunny," "Cloudy"), and a corresponding icon * Display current temperature, weather condition (e.g., "Sunny," "Cloudy"), and a corresponding icon
@@ -76,9 +89,8 @@ npm run dev
* Dynamic Wallpaper-Based Theming * Dynamic Wallpaper-Based Theming
* Automatically adapt the UI's accent color to match the current wallpaper * Automatically adapt the UI's accent color to match the current wallpaper
* Minimal feel toggle * Minimal feel toggle
* Disable title & subtitle and search widget * Disable title and search widget
* Tiles become small stylish lines * Tiles become small stylish lines
From a technical side: From a technical side:
* Refactor everything :( * Refactor everything :(
* Add small nginx demo (with docker)

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { Category } from '../types'; import { Category } from '../types';
interface CategoryEditModalProps { interface CategoryEditModalProps {
@@ -12,10 +12,6 @@ interface CategoryEditModalProps {
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => { const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(category ? category.name : ''); const [name, setName] = useState(category ? category.name : '');
const handleSave = () => {
onSave(name);
};
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
onClose(); onClose();
@@ -23,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={handleSave} 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>

View File

@@ -16,9 +16,26 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
const [time, setTime] = useState(new Date()); const [time, setTime] = useState(new Date());
useEffect(() => { useEffect(() => {
const timerId = setInterval(() => setTime(new Date()), 1000); if (!config.clock.enabled) return;
return () => clearInterval(timerId);
}, []); const scheduleNext = () => {
const now = new Date();
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
timeoutId = window.setTimeout(() => {
setTime(new Date());
intervalId = window.setInterval(() => setTime(new Date()), 60_000);
}, msToNextMinute);
};
let timeoutId = 0;
let intervalId = 0;
scheduleNext();
return () => {
if (timeoutId) window.clearTimeout(timeoutId);
if (intervalId) window.clearInterval(intervalId);
};
}, [config.clock.enabled]);
if (!config.clock.enabled) { if (!config.clock.enabled) {
return null; return null;
@@ -39,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,
}} }}
> >

View File

@@ -1,662 +1,238 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import ToggleSwitch from './ToggleSwitch'; import { Config, Wallpaper } from '../types';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { Server, Wallpaper } from '../types';
import Dropdown from './Dropdown';
import { baseWallpapers } from './utils/baseWallpapers'; import { baseWallpapers } from './utils/baseWallpapers';
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
import { ConfigurationService } from './services/ConfigurationService';
import GeneralTab from './configuration/GeneralTab';
import ThemeTab from './configuration/ThemeTab';
import ClockTab from './configuration/ClockTab';
import ServerWidgetTab from './configuration/ServerWidgetTab';
interface ConfigurationModalProps { interface ConfigurationModalProps {
onClose: () => void; onClose: () => void;
onSave: (config: any) => void; onSave: (config: Config) => void;
currentConfig: any; currentConfig: Config;
onWallpaperChange: (newConfig: Partial<any>) => void; onWallpaperChange: (newConfig: Partial<Config>) => void;
onNextWallpaper: () => void;
} }
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig, onWallpaperChange }) => { const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
const [config, setConfig] = useState({ onClose,
...currentConfig, onSave,
titleSize: currentConfig.titleSize || 'medium', currentConfig,
subtitleSize: currentConfig.subtitleSize || 'medium', onWallpaperChange,
alignment: currentConfig.alignment || 'middle', onNextWallpaper,
tileSize: currentConfig.tileSize || 'medium', }) => {
horizontalAlignment: currentConfig.horizontalAlignment || 'middle', const [config, setConfig] = useState<Config>(currentConfig);
wallpaperBlur: currentConfig.wallpaperBlur || 0,
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
...currentConfig.serverWidget,
},
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
...currentConfig.clock,
},
backgroundUrls: currentConfig.backgroundUrls || [],
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
});
const [activeTab, setActiveTab] = useState('general'); const [activeTab, setActiveTab] = useState('general');
const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]); const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
const menuRef = useRef<HTMLDivElement>(null); const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
const isSaving = useRef(false);
useEffect(() => { useEffect(() => {
const storedUserWallpapers = localStorage.getItem('userWallpapers'); setChromeStorageAvailable(checkChromeStorageLocalAvailable());
if (storedUserWallpapers) { setUserWallpapers(ConfigurationService.loadUserWallpapers());
setUserWallpapers(JSON.parse(storedUserWallpapers));
}
}, []); }, []);
useEffect(() => { useEffect(() => {
// A small timeout to allow the component to mount before starting the transition const timer = setTimeout(() => setIsVisible(true), 10);
const timer = setTimeout(() => {
setIsVisible(true);
}, 10);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (!isSaving.current) { if (!isSaving.current) {
onWallpaperChange({ backgroundUrls: currentConfig.backgroundUrls }); onWallpaperChange({ currentWallpapers: currentConfig.currentWallpapers });
} }
}; };
}, []); }, []);
useEffect(() => {
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
ConfigurationService.resetWallpaperState();
}, [config.currentWallpapers]);
const handleClose = () => { const handleClose = () => {
setIsVisible(false); setIsVisible(false);
setTimeout(() => { setTimeout(onClose, 250);
onClose();
}, 300); // This duration should match the transition duration
}; };
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => { const handleConfigChange = (updates: Partial<Config>) => {
const { name, value } = e.target; setConfig((prev) => ({ ...prev, ...updates }));
if (name.startsWith('serverWidget.')) { };
const field = name.split('.')[1];
setConfig({ const handleAddWallpaper = async (name: string, url: string) => {
...config, const newWallpaper = await ConfigurationService.addWallpaper(name, url);
serverWidget: { ...config.serverWidget, [field]: value }, const updated = [...userWallpapers, newWallpaper];
}); setUserWallpapers(updated);
} else if (name.startsWith('clock.')) { ConfigurationService.saveUserWallpapers(updated);
const field = name.split('.')[1]; setConfig((prev) => ({
setConfig({ ...prev,
...config, currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
clock: { ...config.clock, [field]: value }, }));
}); };
} else {
setConfig({ ...config, [name]: value }); const handleAddWallpaperFile = async (file: File) => {
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
const updated = [...userWallpapers, newWallpaper];
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
setConfig((prev) => ({
...prev,
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
}));
};
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
try {
await ConfigurationService.deleteWallpaper(wallpaper);
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
setUserWallpapers(updated);
ConfigurationService.saveUserWallpapers(updated);
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
} catch (error) {
alert('Error deleting wallpaper. Please try again.');
console.error(error);
} }
}; };
useEffect(() => { const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
onWallpaperChange({ backgroundUrls: config.backgroundUrls }); const file = event.target.files?.[0];
}, [config.backgroundUrls]); if (!file) return;
try {
const handleClockToggleChange = (checked: boolean) => { const { config: importedConfig, userWallpapers: importedWallpapers } =
setConfig({ ...config, clock: { ...config.clock, enabled: checked } }); await ConfigurationService.importConfig(file);
}; setConfig(importedConfig);
setUserWallpapers(importedWallpapers);
const handleServerWidgetToggleChange = (checked: boolean) => { onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
setConfig({ onSave(importedConfig);
...config, alert('Configuration imported successfully. The page will reload to apply all data.');
serverWidget: { ...config.serverWidget, enabled: checked }, window.location.reload();
}); } catch (error) {
}; alert('Could not import configuration. Please use a valid export JSON file.');
console.error(error);
const handleAddServer = () => { } finally {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return; event.target.value = '';
const newServer: Server = {
id: Date.now().toString(),
name: newServerName,
address: newServerAddress,
};
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: [...config.serverWidget.servers, newServer],
},
});
setNewServerName('');
setNewServerAddress('');
};
const handleRemoveServer = (id: string) => {
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: config.serverWidget.servers.filter((server: Server) => server.id !== id),
},
});
};
const onDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(config.serverWidget.servers);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
setConfig({
...config,
serverWidget: {
...config.serverWidget,
servers: items,
},
});
};
const handleAddWallpaper = () => {
if (newWallpaperName.trim() === '' || newWallpaperUrl.trim() === '') return;
const newWallpaper: Wallpaper = {
name: newWallpaperName,
url: newWallpaperUrl,
};
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, newWallpaperUrl] });
setNewWallpaperName('');
setNewWallpaperUrl('');
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 4 * 1024 * 1024) {
alert('File size exceeds 4MB. Please choose a smaller file.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
alert('The uploaded image is too large. Please choose a smaller file.');
return;
}
const updatedUserWallpapers = userWallpapers.filter(w => !w.base64);
const newWallpaper: Wallpaper = {
name: file.name,
base64,
};
setUserWallpapers([...updatedUserWallpapers, newWallpaper]);
localStorage.setItem('userWallpapers', JSON.stringify([...updatedUserWallpapers, newWallpaper]));
setConfig({ ...config, backgroundUrls: [...config.backgroundUrls, base64] });
};
reader.readAsDataURL(file);
} }
}; };
const handleDeleteWallpaper = (wallpaper: Wallpaper) => {
const wallpaperIdentifier = wallpaper.url || wallpaper.base64;
const updatedUserWallpapers = userWallpapers.filter(w => (w.url || w.base64) !== wallpaperIdentifier);
setUserWallpapers(updatedUserWallpapers);
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
const newBackgroundUrls = config.backgroundUrls.filter((url: string) => url !== wallpaperIdentifier);
const newConfig = { ...config, backgroundUrls: newBackgroundUrls };
setConfig(newConfig);
onWallpaperChange({ backgroundUrls: newBackgroundUrls });
};
const allWallpapers = [...baseWallpapers, ...userWallpapers]; const allWallpapers = [...baseWallpapers, ...userWallpapers];
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'theme', label: 'Theme' },
{ id: 'clock', label: 'Clock' },
{ id: 'serverWidget', label: 'Server Widget' },
];
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-300 ease-in-out ${ 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}
></div> />
<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-in-out transform ${ className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
isVisible ? 'translate-x-0' : 'translate-x-full' 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">
<button {tabs.map((tab) => (
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'general' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`} <button
onClick={() => setActiveTab('general')} key={tab.id}
> className={`liquid-focus rounded-xl px-3 py-2 text-sm font-bold transition-all duration-200 ease-ios ${
General activeTab === tab.id
</button> ? 'bg-cyan-400/25 text-cyan-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.28)]'
<button : 'text-slate-300 hover:bg-white/10 hover:text-white'
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'theme' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`} }`}
onClick={() => setActiveTab('theme')} onClick={() => setActiveTab(tab.id)}
> >
Theme {tab.label}
</button> </button>
<button ))}
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'clock' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('clock')}
>
Clock
</button>
<button
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'serverWidget' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
onClick={() => setActiveTab('serverWidget')}
>
Server Widget
</button>
</div> </div>
{activeTab === 'general' && ( {activeTab === 'general' && (
<div className="flex flex-col gap-6"> <GeneralTab config={config} onChange={handleConfigChange} />
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
<input
type="text"
name="title"
value={config.title}
onChange={handleChange}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
<Dropdown
name="titleSize"
value={config.titleSize}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Subtitle</label>
<input
type="text"
name="subtitle"
value={config.subtitle}
onChange={handleChange}
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Subtitle Size</label>
<Dropdown
name="subtitleSize"
value={config.subtitleSize}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
<Dropdown
name="alignment"
value={config.alignment}
onChange={handleChange}
options={[
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
<Dropdown
name="tileSize"
value={config.tileSize}
onChange={handleChange}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
<Dropdown
name="horizontalAlignment"
value={config.horizontalAlignment}
onChange={handleChange}
options={[
{ value: 'left', label: 'Left' },
{ value: 'middle', label: 'Middle' },
{ value: 'right', label: 'Right' },
]}
/>
</div>
</div>
)} )}
{activeTab === 'theme' && ( {activeTab === 'theme' && (
<div className="flex flex-col gap-6"> <ThemeTab
<div className="flex items-center justify-between"> config={config}
<label className="text-slate-300 text-sm font-semibold">Background</label> onChange={handleConfigChange}
<Dropdown userWallpapers={userWallpapers}
name="backgroundUrls" allWallpapers={allWallpapers}
value={config.backgroundUrls} chromeStorageAvailable={chromeStorageAvailable}
onChange={handleChange} onAddWallpaper={handleAddWallpaper}
multiple onAddWallpaperFile={handleAddWallpaperFile}
options={allWallpapers.map(w => ({ onDeleteWallpaper={handleDeleteWallpaper}
value: w.url || w.base64 || '', onNextWallpaper={onNextWallpaper}
label: ( />
<div className="flex items-center justify-between w-full">
<span>{w.name}</span>
{!baseWallpapers.find(bw => (bw.url || bw.base64) === (w.url || w.base64)) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteWallpaper(w);
}}
className="text-red-500 hover:text-red-400 ml-4 p-1 rounded-full flex items-center justify-center"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
)}
</div>
)
}))}
/>
</div>
{Array.isArray(config.backgroundUrls) && config.backgroundUrls.length > 1 && (
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={handleChange}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
/>
</div>
)}
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperBlur"
min="0"
max="50"
value={config.wallpaperBlur}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperBrightness"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
name="wallpaperOpacity"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={handleChange}
className="w-48"
/>
<span>{config.wallpaperOpacity}%</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddWallpaper}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg className="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
</svg>
<p className="mb-2 text-sm text-gray-400"><span className="font-semibold">Click to upload</span> or drag and drop</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
</label>
</div>
</div>
</div>
</div>
)} )}
{activeTab === 'clock' && ( {activeTab === 'clock' && (
<div className="flex flex-col gap-6"> <ClockTab config={config} onChange={handleConfigChange} />
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
<ToggleSwitch
checked={config.clock.enabled}
onChange={handleClockToggleChange}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
<Dropdown
name="clock.size"
value={config.clock.size}
onChange={handleChange}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
<Dropdown
name="clock.font"
value={config.clock.font}
onChange={handleChange}
options={[
{ value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
<Dropdown
name="clock.format"
value={config.clock.format}
onChange={handleChange}
options={[
{ value: 'h:mm A', label: 'AM/PM' },
{ value: 'HH:mm', label: '24:00' },
]}
/>
</div>
</div>
)} )}
{activeTab === 'serverWidget' && ( {activeTab === 'serverWidget' && (
<div className="flex flex-col gap-6"> <ServerWidgetTab config={config} onChange={handleConfigChange} />
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
<ToggleSwitch
checked={config.serverWidget.enabled}
onChange={handleServerWidgetToggleChange}
/>
</div>
{config.serverWidget.enabled && (
<>
<div className="flex items-center justify-between">
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
name="serverWidget.pingFrequency"
min="5"
max="60"
value={config.serverWidget.pingFrequency}
onChange={handleChange}
className="w-48"
/>
<span>{config.serverWidget.pingFrequency}s</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="servers">
{(provided) => (
<div {...provided.droppableProps} ref={provided.innerRef} className="flex flex-col gap-2">
{config.serverWidget.servers.map((server: Server, index: number) => (
<Draggable key={server.id} draggableId={server.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
>
<div>
<p className="font-semibold">{server.name}</p>
<p className="text-sm text-slate-400">{server.address}</p>
</div>
<button
onClick={() => handleRemoveServer(server.id)}
className="text-red-500 hover:text-red-400"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex gap-2 mt-2">
<input
type="text"
placeholder="Server Name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="HTTP Address"
value={newServerAddress}
onChange={(e) => setNewServerAddress(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button
onClick={handleAddServer}
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
>
Add
</button>
</div>
</div>
</>
)}
</div>
)} )}
</div> </div>
<div className="p-8 border-t border-white/10">
<div className="flex justify-end gap-4"> <div className="p-6 sm:p-8 border-t border-white/10">
<button onClick={() => { isSaving.current = true; onSave(config); }} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
Save & Close <div className="flex gap-2">
</button> <button
<button onClick={handleClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg"> onClick={() => ConfigurationService.exportConfig()}
Cancel className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
</button> >
Export
</button>
<button
onClick={() => importInputRef.current?.click()}
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
>
Import
</button>
<input
ref={importInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={handleImportConfig}
/>
</div> </div>
<div className="flex justify-end gap-3">
<button
onClick={() => {
isSaving.current = true;
onSave(config);
}}
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5"
>
Save & Close
</button>
<button
onClick={handleClose}
className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5"
>
Cancel
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default ConfigurationModal; export default ConfigurationModal;

View File

@@ -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}
@@ -79,7 +79,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
> >
<span className="truncate">{selectedOptionLabel}</span> <span className="truncate">{selectedOptionLabel}</span>
<svg <svg
className={`w-5 h-5 transition-transform duration-300 ease-in-out ${isOpen ? 'rotate-180' : 'rotate-0'}`} className={`w-5 h-5 transition-transform duration-200 ease-ios ${isOpen ? 'rotate-180' : 'rotate-0'}`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
@@ -95,18 +95,18 @@ 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-200" 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) => (
<li <li
key={option.value} key={option.value}
onClick={() => handleOptionClick(option.value)} onClick={() => handleOptionClick(option.value)}
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-in-out flex items-center 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>
); );

View File

@@ -1,255 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
import { getWebsiteIcon } from './utils/iconService';
import { Category, Website } from '../types';
import IconPicker from './IconPicker';
import { icons } from 'lucide-react';
interface EditModalProps {
categories: Category[];
onClose: () => void;
onSave: (categories: Category[]) => void;
}
const EditModal: React.FC<EditModalProps> = ({ categories, onClose, onSave }) => {
const [localCategories, setLocalCategories] = useState(categories);
const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id || null);
const [newCategoryName, setNewCategoryName] = useState('');
const [newWebsite, setNewWebsite] = useState({ name: '', url: '', icon: '' });
const [showIconPicker, setShowIconPicker] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleOnDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId === destination.droppableId) {
const category = localCategories.find(cat => cat.id === source.droppableId);
if (category) {
const items = Array.from(category.websites);
const [reorderedItem] = items.splice(source.index, 1);
items.splice(destination.index, 0, reorderedItem);
const updatedCategories = localCategories.map(cat =>
cat.id === category.id ? { ...cat, websites: items } : cat
);
setLocalCategories(updatedCategories);
}
} else {
const sourceCategory = localCategories.find(cat => cat.id === source.droppableId);
const destCategory = localCategories.find(cat => cat.id === destination.droppableId);
if (sourceCategory && destCategory) {
const sourceItems = Array.from(sourceCategory.websites);
const [movedItem] = sourceItems.splice(source.index, 1);
const destItems = Array.from(destCategory.websites);
destItems.splice(destination.index, 0, { ...movedItem, categoryId: destCategory.id });
const updatedCategories = localCategories.map(cat => {
if (cat.id === sourceCategory.id) return { ...cat, websites: sourceItems };
if (cat.id === destCategory.id) return { ...cat, websites: destItems };
return cat;
});
setLocalCategories(updatedCategories);
}
}
};
const handleAddCategory = () => {
if (newCategoryName.trim() === '') return;
const newCategory: Category = {
id: Date.now().toString(),
name: newCategoryName,
websites: [],
};
setLocalCategories([...localCategories, newCategory]);
setNewCategoryName('');
};
const handleRemoveCategory = (id: string) => {
const updatedCategories = localCategories.filter(cat => cat.id !== id);
setLocalCategories(updatedCategories);
if (selectedCategoryId === id) {
setSelectedCategoryId(updatedCategories[0]?.id || null);
}
};
const handleAddWebsite = async () => {
if (!selectedCategoryId || !newWebsite.name || !newWebsite.url) return;
let icon = newWebsite.icon;
if (!icon || !Object.keys(icons).includes(icon)) {
icon = await getWebsiteIcon(newWebsite.url);
}
const newWebsiteData: Website = {
id: Date.now().toString(),
name: newWebsite.name,
url: newWebsite.url,
icon,
categoryId: selectedCategoryId,
};
const updatedCategories = localCategories.map(cat => {
if (cat.id === selectedCategoryId) {
return { ...cat, websites: [...cat.websites, newWebsiteData] };
}
return cat;
});
setLocalCategories(updatedCategories);
setNewWebsite({ name: '', url: '', icon: '' });
};
const handleRemoveWebsite = (categoryId: string, websiteId: string) => {
const updatedCategories = localCategories.map(cat => {
if (cat.id === categoryId) {
return { ...cat, websites: cat.websites.filter(web => web.id !== websiteId) };
}
return cat;
});
setLocalCategories(updatedCategories);
};
const selectedCategory = localCategories.find(cat => cat.id === selectedCategoryId);
return (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50">
<div ref={modalRef} className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-4xl text-white">
<h2 className="text-3xl font-bold mb-6">Edit Bookmarks</h2>
<div className="grid grid-cols-3 gap-8">
<div className="col-span-1">
<h3 className="text-xl font-semibold mb-4">Categories</h3>
<div className="flex flex-col gap-2 mb-4">
{localCategories.map(category => (
<div
key={category.id}
className={`flex justify-between items-center p-3 rounded-lg cursor-pointer ${selectedCategoryId === category.id ? 'bg-cyan-500/50' : 'bg-white/10'}`}
onClick={() => setSelectedCategoryId(category.id)}
>
<span>{category.name}</span>
<button onClick={() => handleRemoveCategory(category.id)} className="text-red-500 hover:text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
placeholder="New Category"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<button onClick={handleAddCategory} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg">
Add
</button>
</div>
</div>
<div className="col-span-2">
<h3 className="text-xl font-semibold mb-4">Websites</h3>
{selectedCategory && (
<DragDropContext onDragEnd={handleOnDragEnd}>
<Droppable droppableId={selectedCategory.id}>
{(provided) => (
<ul {...provided.droppableProps} ref={provided.innerRef} className="mb-8">
{selectedCategory.websites.map((website, index) => (
<Draggable key={website.id} draggableId={website.id} index={index}>
{(provided) => (
<li
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="flex items-center justify-between bg-white/10 p-3 rounded-lg mb-3"
>
<div className="flex items-center">
{Object.keys(icons).includes(website.icon) ? (
React.createElement(icons[website.icon as keyof typeof icons], { className: "h-8 w-8 mr-4" })
) : (
<img src={website.icon} alt={website.name} className="h-8 w-8 mr-4" />
)}
<span>{website.name}</span>
</div>
<button onClick={() => handleRemoveWebsite(selectedCategory.id, website.id)} className="text-red-500 hover:text-red-400">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</li>
)}
</Draggable>
))}
{provided.placeholder}
</ul>
)}
</Droppable>
</DragDropContext>
)}
<div>
<h3 className="text-xl font-semibold mb-4">Add New Bookmark</h3>
<div className="flex flex-col gap-4">
<input
type="text"
placeholder="Name"
value={newWebsite.name}
onChange={(e) => setNewWebsite({ ...newWebsite, name: e.target.value })}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<input
type="text"
placeholder="URL"
value={newWebsite.url}
onChange={(e) => setNewWebsite({ ...newWebsite, url: e.target.value })}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
/>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Icon URL or name"
value={newWebsite.icon}
onChange={(e) => setNewWebsite({ ...newWebsite, icon: e.target.value })}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
/>
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
{showIconPicker ? 'Close' : 'Select Icon'}
</button>
</div>
{showIconPicker && (
<IconPicker
onSelect={(iconName) => {
setNewWebsite({ ...newWebsite, icon: iconName });
setShowIconPicker(false);
}}
/>
)}
<button onClick={handleAddWebsite} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-3 px-4 rounded-lg">
Add Bookmark
</button>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-4 mt-8">
<button onClick={() => onSave(localCategories)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
Save
</button>
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
Close
</button>
</div>
</div>
</div>
);
};
export default EditModal;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Server } from '../types'; import { Server } from '../types';
import ping from './utils/jsping.js'; import ping from './utils/jsping.js';
@@ -12,53 +12,70 @@ interface ServerWidgetProps {
}; };
} }
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'bg-green-400 text-green-400';
case 'offline':
return 'bg-red-400 text-red-400';
default:
return 'bg-slate-400 text-slate-400';
}
};
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => { const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
const [serverStatus, setServerStatus] = useState<Record<string, string>>({}); const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
const serversRef = useRef(config.serverWidget.servers);
serversRef.current = config.serverWidget.servers;
const serversSignature = config.serverWidget.servers
.map(s => `${s.id}:${s.address}`)
.join('|');
const serverCount = config.serverWidget.servers.length;
useEffect(() => { useEffect(() => {
if (!config.serverWidget.enabled) return;
const pingServers = () => { const pingServers = () => {
config.serverWidget.servers.forEach((server) => { const pending: Record<string, string> = {};
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'pending' })); for (const s of serversRef.current) pending[s.id] = 'pending';
setServerStatus(prev => ({ ...prev, ...pending }));
serversRef.current.forEach((server) => {
ping(server.address) ping(server.address)
.then(() => { .then(() => {
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'online' })); setServerStatus(prev =>
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
);
}) })
.catch(() => { .catch(() => {
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'offline' })); setServerStatus(prev =>
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
);
}); });
}); });
}; };
if (config.serverWidget.enabled) { pingServers();
pingServers(); const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000); return () => clearInterval(interval);
return () => clearInterval(interval); }, [
} config.serverWidget.enabled,
}, [config.serverWidget.enabled, config.serverWidget.servers, config.serverWidget.pingFrequency]); config.serverWidget.pingFrequency,
serversSignature,
serverCount,
]);
if (!config.serverWidget.enabled) { if (!config.serverWidget.enabled) {
return null; return null;
} }
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'bg-green-500';
case 'offline':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
return ( 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>
@@ -69,4 +86,4 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
); );
}; };
export default ServerWidget; export default ServerWidget;

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface ToggleSwitchProps { interface ToggleSwitchProps {
checked: boolean; checked: boolean;
@@ -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-300 ${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-300 ${checked ? 'translate-x-6' : 'translate-x-0'}`} className={`bg-white w-6 h-6 rounded-full shadow-lg transform transition-transform duration-200 ease-spring ${checked ? 'translate-x-6' : 'translate-x-0'}`}
/> />
</div> </button>
); );
}; };

146
components/Wallpaper.tsx Normal file
View File

@@ -0,0 +1,146 @@
import { useState, useEffect, useRef } from 'react';
import { baseWallpapers } from './utils/baseWallpapers';
import { Wallpaper as WallpaperType } from '../types';
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
interface WallpaperProps {
wallpaperNames: string[];
blur: number;
brightness: number;
opacity: number;
wallpaperFrequency: string;
wallpaperVersion: number;
}
const parseFrequencyToMs = (freq: string): number => {
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
const match = freq.match(/(\d+)(h|d)/);
if (!match) return 24 * 60 * 60 * 1000;
const value = parseInt(match[1], 10);
const unit = match[2];
if (unit === 'h') return value * 60 * 60 * 1000;
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
return 24 * 60 * 60 * 1000;
};
const wallpaperUrlCache = new Map<string, string | undefined>();
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
if (!name) return undefined;
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
let resolved: string | undefined;
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
if (foundInBase) {
resolved = foundInBase.url || foundInBase.base64;
} else {
try {
const storedUserWallpapers: WallpaperType[] =
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
if (foundInUser) {
try {
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
if (wallpaperData && wallpaperData.startsWith('http')) {
resolved = wallpaperData;
} else {
resolved = wallpaperData || undefined;
}
} catch (error) {
console.error('Error getting wallpaper from chrome storage', error);
resolved = undefined;
}
}
} catch (error) {
console.error('Error reading userWallpapers from localStorage', error);
resolved = undefined;
}
}
wallpaperUrlCache.set(name, resolved);
return resolved;
};
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
const resolvedRef = useRef(false);
useEffect(() => {
const updateWallpaper = async () => {
if (wallpaperNames.length === 0) {
setImageUrl(undefined);
localStorage.setItem(
'wallpaperState',
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
);
return;
}
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
const lastChange = wallpaperState.lastWallpaperChange
? new Date(wallpaperState.lastWallpaperChange).getTime()
: 0;
const now = Date.now();
const freqMs = parseFrequencyToMs(wallpaperFrequency);
let storedIndex =
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
const shouldRotate = now - lastChange >= freqMs;
let resolvedIndex = shouldRotate
? (storedIndex + 1) % wallpaperNames.length
: storedIndex;
const tried = new Set<number>();
let resolvedUrl: string | undefined;
for (let i = 0; i < wallpaperNames.length; i++) {
if (tried.has(resolvedIndex)) break;
tried.add(resolvedIndex);
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
if (url) {
resolvedUrl = url;
break;
}
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
}
const nextLastChange = shouldRotate
? new Date().toISOString()
: wallpaperState.lastWallpaperChange || new Date().toISOString();
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: nextLastChange,
currentIndex: resolvedIndex,
}),
);
resolvedRef.current = true;
setImageUrl(resolvedUrl);
};
updateWallpaper();
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
if (!imageUrl) return null;
return (
<>
<div
className="wallpaper-layer wallpaper-transition"
style={{
backgroundImage: `url(${imageUrl})`,
filter: `blur(${blur}px) brightness(${brightness / 100})`,
opacity: opacity / 100,
}}
aria-label="Wallpaper background"
/>
<div className="wallpaper-luminance" aria-hidden="true" />
</>
);
};
export default Wallpaper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Website } from '../types'; import { Website } from '../types';
import { getWebsiteIcon } from './utils/iconService'; import { getWebsiteIcon } from './utils/iconService';
@@ -22,47 +22,64 @@ interface IconMetadata {
name: string; name: string;
}; };
}; };
colors: any; // this can be anything I guess colors: any;
} }
let iconMetadataCache: IconMetadata[] | null = null;
const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => { const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onClose, onSave, onDelete }) => {
const [name, setName] = useState(website ? website.name : ''); const [name, setName] = useState(website ? website.name : '');
const [url, setUrl] = useState(website ? website.url : ''); const [url, setUrl] = useState(website ? website.url : '');
const [icon, setIcon] = useState(website ? website.icon : ''); const [icon, setIcon] = useState(website ? website.icon : '');
const [iconQuery, setIconQuery] = useState(''); const [iconQuery, setIconQuery] = useState('');
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]); const [filteredIcons, setFilteredIcons] = useState<IconMetadata[]>([]);
const [iconMetadata, setIconMetadata] = useState<IconMetadata[]>([]);
const [iconsFetched, setIconsFetched] = useState(false);
const debounceRef = useRef<number | null>(null);
useEffect(() => { const ensureIconMetadata = () => {
fetch('/icon-metadata.json') if (iconMetadataCache || iconsFetched) return;
setIconsFetched(true);
fetch('/icon-metadata.json', { cache: 'force-cache' })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
const iconsArray = Object.entries(data).map(([name, details]) => ({ const iconsArray: IconMetadata[] = Object.entries(data).map(([name, details]) => ({
name, name,
...details ...(details as object),
})); })) as IconMetadata[];
// Expand colors into separate entries iconMetadataCache = iconsArray;
iconsArray.forEach(icon => {
if (icon.colors) {
const colors = Object.values(icon.colors).filter(key => key !== icon.name);
for (const color of colors) {
const newIcon = { ...icon };
newIcon.name = color;
iconsArray.push(newIcon);
}
}
});
setIconMetadata(iconsArray); setIconMetadata(iconsArray);
}); })
}, []); .catch(err => console.error('Failed to load icon metadata', err));
};
useEffect(() => { useEffect(() => {
if (iconQuery && Array.isArray(iconMetadata)) { if (iconQuery && Array.isArray(iconMetadata) && iconMetadata.length > 0) {
const lowerCaseQuery = iconQuery.toLowerCase(); if (debounceRef.current) window.clearTimeout(debounceRef.current);
const filtered = iconMetadata debounceRef.current = window.setTimeout(() => {
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery)) const lowerCaseQuery = iconQuery.toLowerCase();
.slice(0, 50); const filtered: IconMetadata[] = [];
setFilteredIcons(filtered); for (const ic of iconMetadata) {
if (ic.name.toLowerCase().includes(lowerCaseQuery)) {
filtered.push(ic);
if (filtered.length >= 50) break;
}
if (ic.colors) {
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
for (const color of colors) {
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
filtered.push({ ...ic, name: color });
if (filtered.length >= 50) break;
}
}
if (filtered.length >= 50) break;
}
}
setFilteredIcons(filtered);
}, 150);
return () => {
if (debounceRef.current) window.clearTimeout(debounceRef.current);
};
} else { } else {
setFilteredIcons([]); setFilteredIcons([]);
} }
@@ -86,15 +103,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>
@@ -108,16 +125,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"
@@ -127,10 +144,11 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
setIcon(e.target.value); setIcon(e.target.value);
setIconQuery(e.target.value); setIconQuery(e.target.value);
}} }}
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full" onFocus={ensureIconMetadata}
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}
@@ -139,7 +157,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}`}
@@ -152,7 +170,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>
@@ -160,16 +178,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>
@@ -179,4 +197,4 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
); );
}; };
export default WebsiteEditModal; export default WebsiteEditModal;

44
components/WebsiteTile.tsx Executable file → Normal file
View File

@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { memo, useState } from 'react';
import { Website } from '../types'; import { Website } from '../types';
interface WebsiteTileProps { interface WebsiteTileProps {
website: Website; website: Website;
isEditing: boolean; isEditing: boolean;
@@ -24,13 +23,12 @@ const getTileSizeClass = (size: string | undefined) => {
}; };
// Returns normal icon size in px
const getIconPixelSize = (size: string | undefined): number => { const getIconPixelSize = (size: string | undefined): number => {
switch (size) { switch (size) {
case 'small': case 'small':
return 32; return 34;
case 'medium': case 'medium':
return 40; return 42;
case 'large': case 'large':
return 48; return 48;
default: default:
@@ -38,7 +36,6 @@ const getIconPixelSize = (size: string | undefined): number => {
} }
}; };
// Returns loading icon size in px
const getIconLoadingPixelSize = (size: string | undefined): number => { const getIconLoadingPixelSize = (size: string | undefined): number => {
switch (size) { switch (size) {
case 'small': case 'small':
@@ -62,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-300 ease-in-out`}> <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-300 ease-in-out hover:scale-105 hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75" className={`liquid-surface liquid-tile liquid-focus group flex flex-col items-center justify-center w-full h-full p-4 ${isEditing ? 'pb-6' : ''}`}
aria-label={isEditing ? `${website.name} edit controls` : `Open ${website.name}`}
> >
{isLoading && ( {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-300 ease-in ${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-300 ease-in ${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" /> <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-300 ease-in ${isLoading ? 'text-sm' : ''}`}> <span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
{website.name} {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>
)} )}
@@ -116,4 +108,4 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
); );
}; };
export default WebsiteTile; export default memo(WebsiteTile);

View File

@@ -0,0 +1,69 @@
import React from 'react';
import Dropdown from '../Dropdown';
import ToggleSwitch from '../ToggleSwitch';
import { Config } from '../../types';
interface ClockTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
const updateClock = (updates: Partial<Config['clock']>) => {
onChange({ clock: { ...config.clock, ...updates } });
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
<ToggleSwitch
checked={config.clock.enabled}
onChange={(checked) => updateClock({ enabled: checked })}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
<Dropdown
name="clock.size"
value={config.clock.size}
onChange={(e) => updateClock({ size: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
<Dropdown
name="clock.font"
value={config.clock.font}
onChange={(e) => updateClock({ font: e.target.value as string })}
options={[
{ value: 'Helvetica', label: 'Helvetica' },
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
{ value: 'monospace', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
<Dropdown
name="clock.format"
value={config.clock.format}
onChange={(e) => updateClock({ format: e.target.value as string })}
options={[
{ value: 'h:mm A', label: 'AM/PM' },
{ value: 'HH:mm', label: '24:00' },
]}
/>
</div>
</div>
);
};
export default ClockTab;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import Dropdown from '../Dropdown';
import { Config } from '../../types';
interface GeneralTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
return (
<div className="flex flex-col gap-6">
<div>
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
<input
type="text"
value={config.title}
onChange={(e) => onChange({ title: e.target.value })}
className="liquid-input p-3"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
<Dropdown
name="titleSize"
value={config.titleSize}
onChange={(e) => onChange({ titleSize: e.target.value as string })}
options={[
{ value: 'tiny', label: 'Tiny' },
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
<Dropdown
name="alignment"
value={config.alignment}
onChange={(e) => onChange({ alignment: e.target.value as string })}
options={[
{ value: 'top', label: 'Top' },
{ value: 'middle', label: 'Middle' },
{ value: 'bottom', label: 'Bottom' },
]}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
<Dropdown
name="tileSize"
value={config.tileSize || 'medium'}
onChange={(e) => onChange({ tileSize: e.target.value as string })}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
<Dropdown
name="horizontalAlignment"
value={config.horizontalAlignment}
onChange={(e) => onChange({ horizontalAlignment: e.target.value as string })}
options={[
{ value: 'left', label: 'Left' },
{ value: 'middle', label: 'Middle' },
{ value: 'right', label: 'Right' },
]}
/>
</div>
</div>
);
};
export default GeneralTab;

View File

@@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import ToggleSwitch from '../ToggleSwitch';
import { Config, Server } from '../../types';
interface ServerWidgetTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
}
type RangeStyle = React.CSSProperties & { '--range-progress': string };
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
return { '--range-progress': `${progress}%` };
};
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
const [newServerName, setNewServerName] = useState('');
const [newServerAddress, setNewServerAddress] = useState('');
const updateServerWidget = (updates: Partial<Config['serverWidget']>) => {
onChange({ serverWidget: { ...config.serverWidget, ...updates } });
};
const handleAddServer = () => {
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
const newServer: Server = {
id: Date.now().toString(),
name: newServerName,
address: newServerAddress,
};
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
setNewServerName('');
setNewServerAddress('');
};
const handleRemoveServer = (id: string) => {
updateServerWidget({
servers: config.serverWidget.servers.filter((s) => s.id !== id),
});
};
const onDragEnd = (result: any) => {
if (!result.destination) return;
const items = Array.from(config.serverWidget.servers);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
updateServerWidget({ servers: items });
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
<ToggleSwitch
checked={config.serverWidget.enabled}
onChange={(checked) => updateServerWidget({ enabled: checked })}
/>
</div>
{config.serverWidget.enabled && (
<>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
<div className="flex items-center gap-4">
<input
type="range"
min="5"
max="60"
value={config.serverWidget.pingFrequency}
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="servers">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="flex flex-col gap-2"
>
{config.serverWidget.servers.map((server: Server, index: number) => (
<Draggable key={server.id} draggableId={server.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="liquid-surface flex items-center justify-between rounded-xl p-3"
>
<div>
<p className="font-semibold">{server.name}</p>
<p className="text-sm text-slate-400">{server.address}</p>
</div>
<button
onClick={() => handleRemoveServer(server.id)}
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Remove ${server.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
</button>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex flex-col gap-2 mt-3 sm:flex-row">
<input
type="text"
placeholder="Server Name"
value={newServerName}
onChange={(e) => setNewServerName(e.target.value)}
className="liquid-input p-2.5"
/>
<input
type="text"
placeholder="HTTP Address"
value={newServerAddress}
onChange={(e) => setNewServerAddress(e.target.value)}
className="liquid-input p-2.5"
/>
<button
onClick={handleAddServer}
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
>
Add
</button>
</div>
</div>
</>
)}
</div>
);
};
export default ServerWidgetTab;

View File

@@ -0,0 +1,259 @@
import React, { useRef, useState } from 'react';
import Dropdown from '../Dropdown';
import { Config, Wallpaper } from '../../types';
interface ThemeTabProps {
config: Config;
onChange: (updates: Partial<Config>) => void;
userWallpapers: Wallpaper[];
allWallpapers: Wallpaper[];
chromeStorageAvailable: boolean;
onAddWallpaper: (name: string, url: string) => Promise<void>;
onAddWallpaperFile: (file: File) => Promise<void>;
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
onNextWallpaper: () => void;
}
type RangeStyle = React.CSSProperties & { '--range-progress': string };
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
return { '--range-progress': `${progress}%` };
};
const ThemeTab: React.FC<ThemeTabProps> = ({
config,
onChange,
userWallpapers,
allWallpapers,
chromeStorageAvailable,
onAddWallpaper,
onAddWallpaperFile,
onDeleteWallpaper,
onNextWallpaper,
}) => {
const [newWallpaperName, setNewWallpaperName] = useState('');
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAddWallpaper = async () => {
if (newWallpaperUrl.trim() === '') return;
try {
await onAddWallpaper(newWallpaperName, newWallpaperUrl);
setNewWallpaperName('');
setNewWallpaperUrl('');
} catch (error) {
alert('Error adding wallpaper. Please check the URL and try again.');
console.error(error);
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
await onAddWallpaperFile(file);
} catch (error: any) {
alert(error?.message || 'Error adding wallpaper. Please try again.');
console.error(error);
}
e.target.value = '';
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Background</label>
<Dropdown
name="currentWallpapers"
value={config.currentWallpapers}
onChange={(e) => onChange({ currentWallpapers: e.target.value as string[] })}
multiple
options={allWallpapers.map((w) => ({ value: w.name, label: w.name }))}
/>
</div>
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
<Dropdown
name="wallpaperFrequency"
value={config.wallpaperFrequency}
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
options={[
{ value: '1h', label: '1 hour' },
{ value: '3h', label: '3 hours' },
{ value: '6h', label: '6 hours' },
{ value: '12h', label: '12 hours' },
{ value: '1d', label: '1 day' },
{ value: '2d', label: '2 days' },
]}
/>
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="50"
value={config.wallpaperBlur}
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="200"
value={config.wallpaperBrightness}
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
<div className="flex items-center gap-4">
<input
type="range"
min="1"
max="100"
value={config.wallpaperOpacity}
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
className="liquid-range"
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
/>
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
</div>
</div>
{chromeStorageAvailable && (
<>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
<div className="flex flex-col gap-2">
{userWallpapers.map((wallpaper) => (
<div
key={wallpaper.name}
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
>
<span className="truncate">{wallpaper.name}</span>
<button
onClick={() => onDeleteWallpaper(wallpaper)}
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
aria-label={`Delete ${wallpaper.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
className="bi bi-trash"
viewBox="0 0 16 16"
>
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path
fillRule="evenodd"
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
/>
</svg>
</button>
</div>
))}
</div>
</div>
<div>
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
<div className="flex flex-col gap-2">
<input
type="text"
placeholder="Wallpaper Name (optional for URLs)"
value={newWallpaperName}
onChange={(e) => setNewWallpaperName(e.target.value)}
className="liquid-input p-2.5"
/>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
placeholder="Image URL"
value={newWallpaperUrl}
onChange={(e) => setNewWallpaperUrl(e.target.value)}
className="liquid-input p-2.5"
/>
<button
onClick={handleAddWallpaper}
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
>
Add
</button>
</div>
<div className="flex items-center justify-center w-full">
<label
htmlFor="file-upload"
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<svg
className="w-8 h-8 mb-4 text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 16"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
/>
</svg>
<p className="mb-2 text-sm text-gray-400">
<span className="font-semibold">Click to upload</span> or drag and drop
</p>
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
</div>
<input
id="file-upload"
type="file"
className="hidden"
onChange={handleFileUpload}
ref={fileInputRef}
/>
</label>
</div>
</div>
</div>
</>
)}
<div className="flex justify-center pt-2">
<button
onClick={onNextWallpaper}
disabled={config.currentWallpapers.length === 0}
className="liquid-surface liquid-control liquid-focus disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-2xl"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 1 .5-.5h5.379L8.646 5.354a.5.5 0 1 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708L10.379 8H5a.5.5 0 0 1-.5-.5z" />
</svg>
Next Wallpaper
</button>
</div>
</div>
);
};
export default ThemeTab;

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { memo } from 'react';
import WebsiteTile from '../WebsiteTile'; import WebsiteTile from '../WebsiteTile';
import { Category, Website } from '../../types'; import { Category, Website } from '../../types';
@@ -11,12 +11,23 @@ interface CategoryGroupProps {
setEditingWebsite: (website: Website) => void; setEditingWebsite: (website: Website) => void;
handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void; handleMoveWebsite: (website: Website, direction: 'left' | 'right') => void;
getHorizontalAlignmentClass: (alignment: string) => string; getHorizontalAlignmentClass: (alignment: string) => string;
config: { horizontalAlignment: string;
horizontalAlignment: string; tileSize?: string;
tileSize?: string;
};
} }
const getAddTileSizeClass = (size: string | undefined) => {
switch (size) {
case 'small':
return 'w-28 h-28';
case 'medium':
return 'w-32 h-32';
case 'large':
return 'w-36 h-36';
default:
return 'w-32 h-32';
}
};
const CategoryGroup: React.FC<CategoryGroupProps> = ({ const CategoryGroup: React.FC<CategoryGroupProps> = ({
category, category,
isEditing, isEditing,
@@ -26,27 +37,29 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
setEditingWebsite, setEditingWebsite,
handleMoveWebsite, handleMoveWebsite,
getHorizontalAlignmentClass, getHorizontalAlignmentClass,
config, horizontalAlignment,
tileSize,
}) => { }) => {
return ( return (
<div key={category.id} className="w-full"> <div key={category.id} className="w-full">
<div className={`flex ${getHorizontalAlignmentClass(config.horizontalAlignment)} items-center mb-4 w-full ${config.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 ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.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(config.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}
@@ -54,18 +67,20 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
isEditing={isEditing} isEditing={isEditing}
onEdit={setEditingWebsite} onEdit={setEditingWebsite}
onMove={handleMoveWebsite} onMove={handleMoveWebsite}
tileSize={config.tileSize} tileSize={tileSize}
/> />
))} ))}
{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>
@@ -73,4 +88,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
); );
}; };
export default CategoryGroup; export default memo(CategoryGroup);

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface ConfigurationButtonProps { interface ConfigurationButtonProps {
onClick: () => void; onClick: () => void;
@@ -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 transition-colors" 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>

View File

@@ -1,4 +1,4 @@
import React from 'react';
interface EditButtonProps { interface EditButtonProps {
isEditing: boolean; isEditing: boolean;
@@ -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 transition-colors" 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' : ''}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import Clock from '../Clock'; import Clock from '../Clock';
import { Config } from '../../types'; import { Config } from '../../types';
@@ -51,29 +50,24 @@ const getSubtitleSizeClass = (size: string) => {
} }
}; };
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
const Header: React.FC<HeaderProps> = ({ config }) => { const Header: React.FC<HeaderProps> = ({ config }) => {
return ( return (
<> <>
{config.clock.enabled && ( {config.clock.enabled && (
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto 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.subtitle) && ( {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>
<p
className={`${getSubtitleSizeClass(config.subtitleSize)} text-slate-300`}
style={{ textShadow: '0 1px 3px rgba(0,0,0,0.5)' }}
>
{config.subtitle}
</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,181 @@
import { Config, Wallpaper } from '../../types';
import {
addWallpaperToChromeStorageLocal,
removeWallpaperFromChromeStorageLocal,
} from '../utils/StorageLocalManager';
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
export const DEFAULT_CONFIG: Config = {
title: 'Vision Start',
currentWallpapers: ['Beach'],
wallpaperFrequency: '1d',
wallpaperBlur: 0,
wallpaperBrightness: 108,
wallpaperOpacity: 96,
titleSize: 'medium',
alignment: 'middle',
horizontalAlignment: 'middle',
tileSize: 'medium',
clock: {
enabled: true,
size: 'medium',
font: 'Helvetica',
format: 'h:mm A',
},
serverWidget: {
enabled: false,
pingFrequency: 15,
servers: [],
},
};
const safeParse = (value: string | null): unknown => {
if (value === null) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
};
const toStorageString = (value: unknown): string =>
typeof value === 'string' ? value : JSON.stringify(value);
export const ConfigurationService = {
loadConfig(): Config {
try {
const stored = localStorage.getItem('config');
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_CONFIG, ...parsed };
}
} catch (error) {
console.error('Error parsing config from localStorage', error);
}
return { ...DEFAULT_CONFIG };
},
saveConfig(config: Config): void {
localStorage.setItem('config', JSON.stringify(config));
},
loadUserWallpapers(): Wallpaper[] {
try {
const stored = localStorage.getItem('userWallpapers');
if (stored) return JSON.parse(stored);
} catch {
// ignore
}
return [];
},
saveUserWallpapers(wallpapers: Wallpaper[]): void {
localStorage.setItem('userWallpapers', JSON.stringify(wallpapers));
},
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
const finalName = await addWallpaperToChromeStorageLocal(name, url);
return { name: finalName };
},
async addWallpaperFile(file: File): Promise<Wallpaper> {
return new Promise((resolve, reject) => {
if (file.size > 4 * 1024 * 1024) {
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
return;
}
const reader = new FileReader();
reader.onload = async () => {
const base64 = reader.result as string;
if (base64.length > 4.5 * 1024 * 1024) {
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
return;
}
try {
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
resolve({ name: finalName });
} catch (error) {
reject(error);
}
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
},
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
},
exportConfig(): void {
const exportPayload = {
version: 1,
exportedAt: new Date().toISOString(),
requiredLocalStorageKeys: [...REQUIRED_LOCAL_STORAGE_KEYS],
localStorage: REQUIRED_LOCAL_STORAGE_KEYS.reduce(
(acc, key) => {
acc[key] = safeParse(localStorage.getItem(key));
return acc;
},
{} as Record<RequiredLocalStorageKey, unknown>,
),
};
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `vision-start-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
const fileContent = await file.text();
const parsed = JSON.parse(fileContent);
const localStorageData =
parsed?.localStorage && typeof parsed.localStorage === 'object'
? parsed.localStorage
: parsed;
if (!localStorageData || typeof localStorageData !== 'object') {
throw new Error('Invalid import file format.');
}
let importedAny = false;
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
const rawValue = (localStorageData as Record<string, unknown>)[key];
localStorage.setItem(key, toStorageString(rawValue));
importedAny = true;
}
});
if (!importedAny) {
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
}
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
.userWallpapers as Wallpaper[];
return {
config: importedConfig || { ...DEFAULT_CONFIG },
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
};
},
resetWallpaperState(): void {
localStorage.setItem(
'wallpaperState',
JSON.stringify({
lastWallpaperChange: new Date().toISOString(),
currentIndex: 0,
}),
);
},
};

View File

@@ -0,0 +1,164 @@
// TypeScript interface for window.chrome
declare global {
interface Window {
chrome?: {
storage?: {
local?: {
set: (items: object, callback?: () => void) => void;
get: (keys: string[] | string, callback: (items: { [key: string]: string }) => void) => void;
remove: (keys: string | string[], callback?: () => void) => void;
};
};
runtime?: {
lastError?: { message: string };
};
};
}
}
let isChromeStorageLocalAvailable: boolean | null = null;
/**
* Checks if chrome.storage.local is available and caches the result.
*/
export function checkChromeStorageLocalAvailable(): boolean {
if (isChromeStorageLocalAvailable !== null) return isChromeStorageLocalAvailable;
isChromeStorageLocalAvailable =
typeof window !== 'undefined' &&
typeof window.chrome !== 'undefined' &&
typeof window.chrome.storage !== 'undefined' &&
typeof window.chrome.storage.local !== 'undefined';
return isChromeStorageLocalAvailable;
}
/**
* Adds a new wallpaper to chrome.storage.local.
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL.
* If the URL is not fetchable (e.g., CORS), it will be stored as a URL and the provided name will be used.
* @param name Wallpaper name (string), used as a fallback.
* @param url Wallpaper image URL (string) or base64 data URL.
* @returns Promise<string> The name under which the wallpaper was stored.
* @throws Error if chrome.storage.local is unavailable or if a name is not provided for a non-fetchable URL.
*/
export async function addWallpaperToChromeStorageLocal(name: string, url: string): Promise<string> {
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
if (url.startsWith('data:')) {
// This is a base64 encoded image from a file upload.
// The name is the file name.
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [name]: url }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => name);
}
// This is a URL. Let's try to fetch it.
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch image');
const imageBlob = await response.blob();
const reader = new FileReader();
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
});
// If successful, use the filename from URL as the name.
const finalName = url.substring(url.lastIndexOf('/') + 1).replace(/[?#].*$/, '') || name;
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [finalName]: base64 }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => finalName);
} catch (error) {
// If fetch fails (e.g., CORS), store the URL directly with the user-provided name.
console.warn('Could not fetch wallpaper, storing URL instead. Error:', error);
if (!name) {
throw new Error("A name for the wallpaper is required when the URL can't be accessed.");
}
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.set({ [name]: url }, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
}).then(() => name);
}
}
/**
* Gets a specific wallpaper from chrome.storage.local by name.
* @param name Wallpaper name (string)
* @returns Promise<string | null> (base64 string or null)
* @throws Error if chrome.storage.local is unavailable
*/
export async function getWallpaperFromChromeStorageLocal(name: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
return new Promise<string | null>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve(result[name] || null);
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
}
/**
* Removes a wallpaper from chrome.storage.local by name.
* @param name Wallpaper name (string)
* @returns Promise<void>
* @throws Error if chrome.storage.local is unavailable
*/
export async function removeWallpaperFromChromeStorageLocal(name: string): Promise<void> {
if (!checkChromeStorageLocalAvailable()) {
throw new Error('chrome.storage.local is not available');
}
return new Promise<void>((resolve, reject) => {
if (window.chrome?.storage?.local) {
window.chrome.storage.local.remove(name, function () {
if (window.chrome?.runtime?.lastError) {
reject(new Error(window.chrome.runtime.lastError.message));
} else {
resolve();
}
});
} else {
reject(new Error('chrome.storage.local is not available'));
}
});
}

View File

@@ -1,24 +1,38 @@
function request_image(url) { function request_image(url) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var img = new Image(); var img = new Image();
img.onload = function() { resolve(img); }; var settled = false;
img.onerror = function() { reject(url); }; function settleOk() {
if (settled) return;
settled = true;
clearTimeout(failTimer);
img.onload = img.onerror = null;
resolve(img);
}
function settleFail() {
if (settled) return;
settled = true;
clearTimeout(failTimer);
img.onload = img.onerror = null;
reject(url);
}
img.onload = settleOk;
img.onerror = settleFail;
img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16); img.src = url + '?random-no-cache=' + Math.floor((1 + Math.random()) * 0x10000).toString(16);
var failTimer = setTimeout(settleFail, 5000);
}); });
} }
function ping(url, multiplier) { function ping(url, multiplier) {
return new Promise(function(resolve, reject) { return new Promise(function(resolve, reject) {
var start = (new Date()).getTime(); var start = (new Date()).getTime();
var response = function() { var response = function() {
var delta = ((new Date()).getTime() - start); var delta = ((new Date()).getTime() - start);
delta *= (multiplier || 1); delta *= (multiplier || 1);
resolve(delta); resolve(delta);
}; };
request_image(url).then(response).catch(response); request_image(url).then(response).catch(response);
setTimeout(function() { reject(Error('Timeout')); }, 5000);
}); });
} }
export default ping; export default ping;

0
constants.tsx Executable file → Normal file
View File

0
icon.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 763 KiB

550
index.css
View File

@@ -1 +1,549 @@
@import "tailwindcss"; @import "tailwindcss";
@theme {
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-liquid: cubic-bezier(0.2, 0.8, 0.2, 1);
}
html {
background: #0f1720;
}
body {
min-width: 320px;
margin: 0;
background: #0f1720;
color: white;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#root {
min-height: 100vh;
}
.vision-shell {
position: relative;
isolation: isolate;
overflow-x: hidden;
background:
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.06), transparent 28rem),
radial-gradient(circle at 82% 20%, rgba(34, 211, 238, 0.05), transparent 26rem),
linear-gradient(135deg, rgba(241, 245, 249, 0.04), rgba(15, 23, 42, 0.16) 52%, rgba(8, 13, 20, 0.32));
}
.vision-shell::before {
content: "";
position: fixed;
inset: 0;
z-index: -15;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 38%, rgba(2, 6, 23, 0.3) 100%),
radial-gradient(ellipse at center, transparent 0%, rgba(2, 6, 23, 0.18) 78%, rgba(2, 6, 23, 0.36) 100%);
}
.wallpaper-transition {
transition: filter 0.7s var(--ease-liquid), opacity 0.7s var(--ease-liquid), transform 0.7s var(--ease-liquid);
}
.wallpaper-layer {
position: fixed;
inset: 0;
z-index: -30;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
transform: scale(1.015);
will-change: filter, opacity, transform;
}
.wallpaper-luminance {
position: fixed;
inset: 0;
z-index: -25;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 34%, rgba(5, 12, 20, 0.34)),
radial-gradient(circle at 50% 38%, rgba(255, 255, 255, 0.05), transparent 28rem),
radial-gradient(circle at 50% 100%, rgba(8, 13, 24, 0.36), transparent 36rem);
}
.liquid-surface {
position: relative;
overflow: hidden;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.04) 46%, rgba(15, 23, 42, 0.18)),
rgba(8, 13, 23, 0.3);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -10px 20px rgba(2, 6, 23, 0.12),
0 12px 28px rgba(2, 6, 23, 0.2);
-webkit-backdrop-filter: blur(10px) saturate(112%);
backdrop-filter: blur(10px) saturate(112%);
}
.liquid-surface::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.1), transparent 34%),
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.07), transparent 42%);
opacity: 0.28;
}
.liquid-panel {
position: relative;
overflow: hidden;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04) 48%, rgba(5, 12, 22, 0.34)),
rgba(8, 13, 23, 0.48);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 22px 56px rgba(2, 6, 23, 0.3);
-webkit-backdrop-filter: blur(14px) saturate(115%);
backdrop-filter: blur(14px) saturate(115%);
}
.liquid-control {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-width: 2.75rem;
min-height: 2.75rem;
color: white;
transition:
transform 180ms var(--ease-ios),
background 180ms var(--ease-ios),
border-color 180ms var(--ease-ios),
box-shadow 180ms var(--ease-ios),
color 180ms var(--ease-ios);
}
.liquid-control:hover {
transform: translateY(-1px) scale(1.025);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)),
rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.22);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 14px 30px rgba(2, 6, 23, 0.22);
}
.liquid-control:active {
transform: scale(0.94);
}
.liquid-focus {
outline: none;
}
.liquid-focus:focus-visible {
outline: 2px solid rgba(34, 211, 238, 0.9);
outline-offset: 3px;
}
.liquid-input {
width: 100%;
color: white;
background: rgba(255, 255, 255, 0.075);
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 0.875rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition:
background 180ms var(--ease-ios),
border-color 180ms var(--ease-ios),
box-shadow 180ms var(--ease-ios);
}
.liquid-input::placeholder {
color: rgba(226, 232, 240, 0.58);
}
.liquid-input:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.18);
}
.liquid-input:focus {
outline: none;
border-color: rgba(34, 211, 238, 0.76);
box-shadow:
0 0 0 3px rgba(34, 211, 238, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
.liquid-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.875rem;
font-weight: 700;
color: white;
transition:
transform 150ms var(--ease-ios),
filter 150ms var(--ease-ios),
background 150ms var(--ease-ios),
box-shadow 150ms var(--ease-ios);
}
.liquid-button:hover {
transform: translateY(-1px);
filter: brightness(1.08);
}
.liquid-button:active {
transform: scale(0.96);
}
.liquid-button-primary {
background: linear-gradient(135deg, #06b6d4, #22d3ee);
box-shadow: 0 14px 28px rgba(8, 145, 178, 0.22);
}
.liquid-button-success {
background: linear-gradient(135deg, #10b981, #22c55e);
box-shadow: 0 14px 28px rgba(34, 197, 94, 0.2);
}
.liquid-button-secondary {
background: rgba(100, 116, 139, 0.74);
}
.liquid-button-danger {
background: linear-gradient(135deg, #ef4444, #fb7185);
}
.liquid-range {
--range-progress: 50%;
width: clamp(10rem, 36vw, 13.5rem);
height: 1.6rem;
appearance: none;
background: transparent;
accent-color: #22d3ee;
cursor: pointer;
}
.liquid-range:focus {
outline: none;
}
.liquid-range:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.liquid-range::-webkit-slider-runnable-track {
height: 0.68rem;
border-radius: 999px;
background:
linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78)) 0 / var(--range-progress) 100% no-repeat,
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
rgba(8, 13, 23, 0.4);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
0 8px 18px rgba(2, 6, 23, 0.14);
transition:
border-color 180ms var(--ease-ios),
box-shadow 180ms var(--ease-ios);
}
.liquid-range:hover::-webkit-slider-runnable-track {
border-color: rgba(255, 255, 255, 0.24);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 -1px 2px rgba(2, 6, 23, 0.2),
0 10px 22px rgba(2, 6, 23, 0.18);
}
.liquid-range::-webkit-slider-thumb {
width: 1.18rem;
height: 1.18rem;
margin-top: -0.31rem;
appearance: none;
border-radius: 999px;
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
border: 1px solid rgba(255, 255, 255, 0.86);
box-shadow:
0 0 0 4px rgba(34, 211, 238, 0.1),
0 8px 18px rgba(2, 6, 23, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition:
transform 180ms var(--ease-ios),
box-shadow 180ms var(--ease-ios),
border-color 180ms var(--ease-ios);
}
.liquid-range:hover::-webkit-slider-thumb {
transform: scale(1.06);
box-shadow:
0 0 0 5px rgba(34, 211, 238, 0.15),
0 10px 22px rgba(2, 6, 23, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.liquid-range:active::-webkit-slider-thumb {
transform: scale(0.94);
}
.liquid-range:focus-visible::-webkit-slider-thumb {
border-color: rgba(34, 211, 238, 0.9);
box-shadow:
0 0 0 4px rgba(34, 211, 238, 0.24),
0 0 0 7px rgba(255, 255, 255, 0.08),
0 10px 22px rgba(2, 6, 23, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.liquid-range::-moz-range-track {
height: 0.68rem;
border-radius: 999px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
rgba(8, 13, 23, 0.4);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.15),
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
0 8px 18px rgba(2, 6, 23, 0.14);
}
.liquid-range::-moz-range-progress {
height: 0.68rem;
border-radius: 999px;
background: linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78));
}
.liquid-range::-moz-range-thumb {
width: 1.18rem;
height: 1.18rem;
border-radius: 999px;
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
border: 1px solid rgba(255, 255, 255, 0.86);
box-shadow:
0 0 0 4px rgba(34, 211, 238, 0.1),
0 8px 18px rgba(2, 6, 23, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition:
transform 180ms var(--ease-ios),
box-shadow 180ms var(--ease-ios),
border-color 180ms var(--ease-ios);
}
.liquid-range:hover::-moz-range-thumb {
transform: scale(1.06);
box-shadow:
0 0 0 5px rgba(34, 211, 238, 0.15),
0 10px 22px rgba(2, 6, 23, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.liquid-range:active::-moz-range-thumb {
transform: scale(0.94);
}
.liquid-range:focus-visible::-moz-range-thumb {
border-color: rgba(34, 211, 238, 0.9);
box-shadow:
0 0 0 4px rgba(34, 211, 238, 0.24),
0 0 0 7px rgba(255, 255, 255, 0.08),
0 10px 22px rgba(2, 6, 23, 0.32),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
}
.liquid-tile {
border-radius: 1.35rem;
transition:
transform 240ms var(--ease-liquid),
background 260ms var(--ease-ios),
border-color 260ms var(--ease-ios),
box-shadow 340ms var(--ease-liquid);
will-change: transform, box-shadow;
}
.liquid-tile:hover {
transform: translateY(-3px) scale(1.025);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08) 46%, rgba(34, 211, 238, 0.08)),
rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.24);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.16),
0 18px 34px rgba(2, 6, 23, 0.24),
0 0 20px rgba(34, 211, 238, 0.04);
}
.liquid-tile:active {
transform: scale(0.965);
}
.liquid-ghost-tile {
border-radius: 1.35rem;
color: rgba(255, 255, 255, 0.72);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.32);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)),
rgba(255, 255, 255, 0.03);
}
.liquid-ghost-tile:hover {
color: white;
border-color: rgba(34, 211, 238, 0.62);
background:
linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
}
.liquid-edit-toolbar {
position: absolute;
left: 50%;
bottom: -0.9rem;
z-index: 5;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.25rem;
border-radius: 999px;
transform: translateX(-50%);
}
.liquid-edit-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.8rem;
height: 1.8rem;
border-radius: 999px;
color: rgba(255, 255, 255, 0.72);
transition:
color 150ms var(--ease-ios),
background 150ms var(--ease-ios),
transform 150ms var(--ease-ios);
}
.liquid-edit-action:hover {
color: white;
background: rgba(255, 255, 255, 0.16);
transform: scale(1.08);
}
.liquid-edit-action:active {
transform: scale(0.92);
}
.liquid-category-title {
text-shadow: 0 2px 16px rgba(2, 6, 23, 0.45);
letter-spacing: 0;
}
.liquid-clock-text,
.liquid-title-text {
text-shadow:
0 2px 18px rgba(2, 6, 23, 0.45),
0 0 28px rgba(255, 255, 255, 0.12);
letter-spacing: 0;
}
.liquid-drawer {
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03) 38%, rgba(7, 12, 22, 0.44)),
rgba(7, 12, 22, 0.68);
border-left: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
inset 1px 0 0 rgba(255, 255, 255, 0.08),
-26px 0 64px rgba(2, 6, 23, 0.3);
-webkit-backdrop-filter: blur(16px) saturate(112%);
backdrop-filter: blur(16px) saturate(112%);
}
.liquid-modal-backdrop {
background:
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.04), transparent 32rem),
rgba(3, 7, 18, 0.72);
-webkit-backdrop-filter: blur(6px) saturate(108%);
backdrop-filter: blur(6px) saturate(108%);
}
.liquid-modal-card {
overflow: visible;
animation: liquid-pop 240ms var(--ease-spring);
}
.liquid-dropdown-list {
position: absolute;
top: 100%;
left: 0;
overflow-x: hidden;
overflow-y: auto;
animation: liquid-drop 150ms var(--ease-ios);
transform-origin: top;
}
.liquid-status-dot {
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.1),
0 0 18px currentColor;
}
@keyframes liquid-pop {
from {
opacity: 0;
transform: translateY(0.75rem) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes liquid-drop {
from {
opacity: 0;
transform: translateY(-0.35rem) scaleY(0.97);
}
to {
opacity: 1;
transform: translateY(0) scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
.liquid-control:hover,
.liquid-tile:hover,
.liquid-button:hover,
.liquid-edit-action:hover,
.liquid-range:hover::-webkit-slider-thumb,
.liquid-range:active::-webkit-slider-thumb,
.liquid-range:hover::-moz-range-thumb,
.liquid-range:active::-moz-range-thumb {
transform: none;
}
.liquid-range::-webkit-slider-runnable-track,
.liquid-range::-webkit-slider-thumb,
.liquid-range::-moz-range-track,
.liquid-range::-moz-range-progress,
.liquid-range::-moz-range-thumb {
transition: none;
}
}

4
index.html Executable file → Normal file
View File

@@ -8,8 +8,8 @@
<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>
</html> </html>

0
index.tsx Executable file → Normal file
View File

13
nginx.conf Normal file
View File

@@ -0,0 +1,13 @@
gzip on;
gzip_comp_level 6;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/json
application/javascript
application/xml
text/css
text/plain
text/xml
image/svg+xml;

616
package-lock.json generated
View File

@@ -10,14 +10,14 @@
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"react": "^19.1.0", "preact": "^10.26.4"
"react-dom": "^19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@vitejs/plugin-react": "^4.7.0", "@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
@@ -52,13 +52,13 @@
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
@@ -108,14 +108,14 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.28.0", "version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.28.0", "@babel/parser": "^7.29.0",
"@babel/types": "^7.28.0", "@babel/types": "^7.29.0",
"@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28", "@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2" "jsesc": "^3.0.2"
@@ -124,6 +124,19 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
"integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.3"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
@@ -152,14 +165,14 @@
} }
}, },
"node_modules/@babel/helper-module-imports": { "node_modules/@babel/helper-module-imports": {
"version": "7.27.1", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/traverse": "^7.27.1", "@babel/traverse": "^7.28.6",
"@babel/types": "^7.27.1" "@babel/types": "^7.28.6"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -184,9 +197,9 @@
} }
}, },
"node_modules/@babel/helper-plugin-utils": { "node_modules/@babel/helper-plugin-utils": {
"version": "7.27.1", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
"integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -204,9 +217,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.27.1", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -238,13 +251,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.28.0", "version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.28.0" "@babel/types": "^7.29.0"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@@ -253,14 +266,14 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/plugin-transform-react-jsx-self": { "node_modules/@babel/plugin-syntax-jsx": {
"version": "7.27.1", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.28.6"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -269,14 +282,34 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-react-jsx-source": { "node_modules/@babel/plugin-transform-react-jsx": {
"version": "7.27.1", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-module-imports": "^7.28.6",
"@babel/helper-plugin-utils": "^7.28.6",
"@babel/plugin-syntax-jsx": "^7.28.6",
"@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-development": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
"integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -295,33 +328,33 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.27.2", "@babel/parser": "^7.28.6",
"@babel/types": "^7.27.1" "@babel/types": "^7.28.6"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.28.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.28.0", "@babel/generator": "^7.29.0",
"@babel/helper-globals": "^7.28.0", "@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.0", "@babel/parser": "^7.29.0",
"@babel/template": "^7.27.2", "@babel/template": "^7.28.6",
"@babel/types": "^7.28.0", "@babel/types": "^7.29.0",
"debug": "^4.3.1" "debug": "^4.3.1"
}, },
"engines": { "engines": {
@@ -329,14 +362,14 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.28.1", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1" "@babel/helper-validator-identifier": "^7.28.5"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -807,9 +840,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.4", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
@@ -822,13 +855,121 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@rolldown/pluginutils": { "node_modules/@preact/preset-vite": {
"version": "1.0.0-beta.27", "version": "2.10.5",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/plugin-transform-react-jsx": "^7.27.1",
"@babel/plugin-transform-react-jsx-development": "^7.27.1",
"@prefresh/vite": "^2.4.11",
"@rollup/pluginutils": "^5.0.0",
"babel-plugin-transform-hook-names": "^1.0.2",
"debug": "^4.4.3",
"magic-string": "^0.30.21",
"picocolors": "^1.1.1",
"vite-prerender-plugin": "^0.5.8",
"zimmerframe": "^1.1.4"
},
"peerDependencies": {
"@babel/core": "7.x",
"vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/@prefresh/babel-plugin": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz",
"integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@prefresh/core": {
"version": "1.5.9",
"resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz",
"integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"preact": "^10.0.0 || ^11.0.0-0"
}
},
"node_modules/@prefresh/utils": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz",
"integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==",
"dev": true,
"license": "MIT"
},
"node_modules/@prefresh/vite": {
"version": "2.4.12",
"resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz",
"integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.22.1",
"@prefresh/babel-plugin": "^0.5.2",
"@prefresh/core": "^1.5.0",
"@prefresh/utils": "^1.2.0",
"@rollup/pluginutils": "^4.2.1"
},
"peerDependencies": {
"preact": "^10.4.0 || ^11.0.0-0",
"vite": ">=2.0.0"
}
},
"node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
"integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"estree-walker": "^2.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/@prefresh/vite/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.45.0", "version": "4.45.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz",
@@ -1365,51 +1506,6 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.20.7",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1427,13 +1523,23 @@
} }
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.8", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
@@ -1442,27 +1548,6 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -1501,6 +1586,23 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
"integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@babel/core": "^7.12.10"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.1", "version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -1580,17 +1682,47 @@
"tiny-invariant": "^1.0.6" "tiny-invariant": "^1.0.6"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1614,6 +1746,65 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.187", "version": "1.5.187",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz",
@@ -1634,6 +1825,19 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.6", "version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
@@ -1685,6 +1889,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.4.6", "version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
@@ -1743,6 +1954,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
@@ -1785,6 +2006,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.30.1", "version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@@ -2031,12 +2259,12 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/minipass": { "node_modules/minipass": {
@@ -2100,6 +2328,17 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-html-parser": {
"version": "6.1.13",
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
"integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"he": "1.2.0"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.19", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -2117,6 +2356,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2170,6 +2422,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/preact": {
"version": "10.29.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz",
"integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/raf-schd": { "node_modules/raf-schd": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -2181,6 +2443,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2190,6 +2453,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -2220,16 +2484,6 @@
} }
} }
}, },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/redux": { "node_modules/redux": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -2279,7 +2533,8 @@
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
@@ -2291,6 +2546,26 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/simple-code-frame": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz",
"integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"kolorist": "^1.6.0"
}
},
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2300,6 +2575,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stack-trace": {
"version": "1.0.0-pre2",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz",
"integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
@@ -2489,6 +2774,24 @@
} }
} }
}, },
"node_modules/vite-prerender-plugin": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz",
"integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"kolorist": "^1.8.0",
"magic-string": "0.x >= 0.26.0",
"node-html-parser": "^6.1.12",
"simple-code-frame": "^1.3.0",
"source-map": "^0.7.4",
"stack-trace": "^1.0.0-pre2"
},
"peerDependencies": {
"vite": "5.x || 6.x || 7.x || 8.x"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -2497,6 +2800,13 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
} }
} }
} }

6
package.json Executable file → Normal file
View File

@@ -11,14 +11,14 @@
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"react": "^19.1.0", "preact": "^10.26.4"
"react-dom": "^19.1.0"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.10.1",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@vitejs/plugin-react": "^4.7.0", "@types/react-dom": "^19.1.5",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",

256
project-context.md Normal file
View File

@@ -0,0 +1,256 @@
---
project_name: Vision Start
date: 2026-07-03
type: general_overview
---
# Vision Start — Project Context
A general, non-normative overview of the **Vision Start** project: what it is, how it's structured, what features it offers, and which files do what. This document is intended as a map for humans and AI agents to orient themselves in the codebase. It deliberately avoids coding-style prescriptive rules.
---
## 1. What Is This Project?
**Vision Start** is a glassmorphism-styled, highly customizable **browser startpage** (new-tab page).
- Distributed as a **Chrome/Chromium extension** (Manifest V3) that overrides the new tab with `index.html`.
- Also runnable as a **standalone web app** and shipped as a **Docker image** served via nginx.
- Built with **React + TypeScript**, bundled with **Vite**, and styled with **Tailwind CSS v4** (using the `@preact/preset-vite` so Preact is the actual React runtime).
- Persistent state lives in `localStorage` and, when available, `chrome.storage.local`.
Live instances / artifacts:
- Public demo: `http://vision-start.ivanch.me`
- Source: `https://gitea.com/ivan/vision-start.git`
- Container registry: `git.ivanch.me/ivanch/vision-start`
- Releases: `https://git.ivanch.me/ivanch/vision-start/releases/latest`
---
## 2. Technology Stack
| Layer | Tech |
|---|---|
| Language | TypeScript (~5.7), target ES2020, strict mode |
| UI runtime | Preact 10 (via `@preact/preset-vite`); types from `@types/react` 19 |
| Bundler / dev server | Vite 6 |
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
| Drag & drop | `@hello-pangea/dnd` 18 |
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) |
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
Entry points: `index.html``index.tsx``App.tsx`.
---
## 3. Feature Overview
The startpage is composed of widgets and a configuration panel:
- **Website Tiles** — Bookmarks organized into categories. Each tile shows an icon + name and opens the configured URL. Tiles can be added, edited, deleted, and moved left/right within their own category (reordering) while in edit mode.
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
- **Title** — Optional big header title (text + size configurable).
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at a cadence (`1h``2d`).
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
Performance notes:
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
- `WebsiteTile` and `CategoryGroup` are wrapped in `React.memo`; `App.tsx` handlers are `useCallback`-stabilized and pure alignment helpers are hoisted to module scope, so opening a modal / toggling edit no longer re-renders every tile.
- `Clock` updates on the minute boundary (one `setTimeout``setInterval(60_000)`) instead of every second.
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
- Icon metadata (`/icon-metadata.json`) is module-level cached, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
Planned / To-do (tracked in `README.md`):
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
---
## 4. Project Structure
```
vision-start/
├── App.tsx # Root React component; central state + handlers
├── index.tsx # React root mount (ReactDOM.createRoot)
├── index.html # HTML shell, links index.css, mounts #root
├── index.css # Tailwind v4 import + liquid glass utilities, wallpaper overlays, and easing tokens
├── vite-env.d.ts # Type declaration for CSS imports used by TypeScript verification
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
├── constants.tsx # DEFAULT_CATEGORIES seed data
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
├── icon.png # Extension icon source
├── components/
│ ├── Clock.tsx # Header clock widget
│ ├── Wallpaper.tsx # Background image renderer + rotation logic
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
│ ├── CategoryEditModal.tsx # Add/edit a category
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
│ ├── ServerWidget.tsx # Bottom server status pill
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
│ ├── ToggleSwitch.tsx # Reusable toggle switch
│ │
│ ├── layout/
│ │ ├── Header.tsx # Renders Clock + Title
│ │ ├── CategoryGroup.tsx # Renders a category's title + its tiles + edit controls
│ │ ├── EditButton.tsx # Top-left pencil toggle
│ │ └── ConfigurationButton.tsx # Top-right gear button
│ │
│ ├── configuration/
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
│ │
│ ├── services/
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
│ │
│ └── utils/
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
│ ├── iconService.ts # getWebsiteIcon: fetch HTML, parse apple-touch-icon/icon, fallback to Google favicons
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper fetch/base64/URL storage
├── public/
│ ├── favicon.ico
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
├── screenshots/ # README screenshots (dark page, editing, configuration)
├── scripts/
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
├── .gitea/workflows/
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container
├── .dockerignore
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
├── tailwind.config.js # Content globs + safelist of dynamic w-/h- sizes
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
├── package.json # Scripts: dev, build, preview
├── README.md # Public-facing readme + feature list + roadmap
└── .env.local # Local env (not tracked in context here)
```
---
## 5. Data Model & State
The shape of all persisted data lives in `types.ts`:
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
- **`Server`** — `id`, `name`, `address`
- **`Category`** — `id`, `name`, `websites: Website[]`
- **`Wallpaper`** — `name`, optional `url` or `base64`
- **`Config`** — Everything else: title, wallpaper list + 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.
Storage layout (browser-side):
| Key | Where | Contents |
|---|---|---|
| `config` | `localStorage` | The full `Config` JSON |
| `categories` | `localStorage` | `Category[]` JSON |
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data |
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`.
---
## 6. Application Flow (high level)
1. `index.html` loads `index.tsx`, which mounts `<App/>` into `#root`.
2. `App.tsx` initializes state from `localStorage` (`categories`) and `ConfigurationService.loadConfig()` (`config`), falling back to defaults.
3. `useEffect` hooks persist `config` and `categories` back to `localStorage` whenever they change.
4. The screen renders:
- `<Wallpaper>` behind everything (fetches URL/base64 from base catalog or chrome.storage.local; rotates per frequency).
- `<EditButton>` (top-left) and `<ConfigurationButton>` (top-right).
- `<Header>` (clock + title).
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
- Optional `<ServerWidget>` if enabled.
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
---
## 7. Build, Release & Deployment
### Local development / preview
```bash
npm install
npm run dev # Vite dev server (note: PROJECT.md says prefer `npm run build` for real testing)
npm run build # Production build → dist/
npm run preview # Serve built dist/
```
### Chrome extension install (manual)
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
### Docker
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh``npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
### CI/CD (Gitea Actions)
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
Required CI secrets (referenced by the workflows): `REGISTRY_PASSWORD`, `HOST`, `USERNAME`, `KEY`, `PORT`, `STAGING_DIR`, `PROD_DIR`, `VIRUSTOTAL_APIKEY`.
External assets fetched at build time by `scripts/prepare_release.sh`:
- `https://raw.githubusercontent.com/homarr-labs/dashboard-icons/.../metadata.json``public/icon-metadata.json` (used by `WebsiteEditModal` for the icon picker; gitignored).
---
## 8. Notable Behaviors & Quirks
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
---
## 9. Where Things Live (Quick Lookup)
| You want to find… | Look in… |
|---|---|
| The default config | `components/services/ConfigurationService.ts` (`DEFAULT_CONFIG`) |
| The default seed bookmarks | `constants.tsx` (`DEFAULT_CATEGORIES`) |
| Built-in wallpaper list | `components/utils/baseWallpapers.ts` |
| Type definitions | `types.ts` |
| Main app wiring (state, handlers, layout) | `App.tsx` |
| Settings UI | `components/ConfigurationModal.tsx` + `components/configuration/*Tab.tsx` |
| Wallpaper rendering/rotation | `components/Wallpaper.tsx` |
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
| Release packaging | `scripts/prepare_release.sh`, `scripts/check_virustotal.sh`, `.gitea/workflows/release.yaml` |
| Docker build | `Dockerfile` |
---
_Last updated: 2026-07-03. Generated as a general project overview; not a coding-style guide._

0
public/favicon.ico Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 763 KiB

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

BIN
screenshots/editing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

113
scripts/check_virustotal.sh Normal file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Script to check a file against VirusTotal API
# Requires: curl, jq
# Environment variable: virustotal_apikey
set -e
# Configuration
FILE_PATH="${VIRUS_TOTAL_FILE:-vision-start.zip}"
API_KEY="${virustotal_apikey}"
BASE_URL="https://www.virustotal.com/api/v3"
# Check if API key is set
if [ -z "$API_KEY" ]; then
echo "Error: virustotal_apikey environment variable is not set"
exit 1
fi
# Check if file exists
if [ ! -f "$FILE_PATH" ]; then
echo "Error: File $FILE_PATH not found"
exit 1
fi
# Check if required tools are available
if ! command -v curl &> /dev/null; then
echo "Error: curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Error: jq is required but not installed"
exit 1
fi
echo "Uploading $FILE_PATH to VirusTotal for analysis..."
# Upload file to VirusTotal
UPLOAD_RESPONSE=$(curl -s -X POST \
-H "x-apikey: $API_KEY" \
-F "file=@$FILE_PATH" \
"$BASE_URL/files")
# Extract scan_id from response
SCAN_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.data.id')
if [ "$SCAN_ID" == "null" ] || [ -z "$SCAN_ID" ]; then
echo "Error: Failed to upload file or get scan ID"
echo "Response: $UPLOAD_RESPONSE"
exit 1
fi
echo "File uploaded successfully. Scan ID: $SCAN_ID"
echo "Waiting for analysis to complete..."
# Wait for analysis to complete and get results
MAX_ATTEMPTS=60
ATTEMPT=0
SLEEP_INTERVAL=10
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
echo "Checking analysis status (attempt $((ATTEMPT + 1))/$MAX_ATTEMPTS)..."
# Get scan report
REPORT_RESPONSE=$(curl -s -X GET \
-H "x-apikey: $API_KEY" \
"$BASE_URL/analyses/$SCAN_ID")
# Check if analysis is complete
RESPONSE_CODE=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.status')
if [ "$RESPONSE_CODE" == "completed" ]; then
# Analysis complete
echo "Analysis completed!"
# Extract results
POSITIVES=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.malicious')
SUSPICIOUS=$(echo "$REPORT_RESPONSE" | jq -r '.data.attributes.stats.suspicious')
# The v3 analyses object has no 'total' field — compute it by summing all stat categories
TOTAL=$(echo "$REPORT_RESPONSE" | jq '[.data.attributes.stats | to_entries[].value] | add')
ANALYSIS_ID=$(echo "$REPORT_RESPONSE" | jq -r '.data.id')
PERMALINK="https://www.virustotal.com/gui/file-analysis/${ANALYSIS_ID}"
echo "Analysis URL: $PERMALINK"
echo "Detection ratio: $POSITIVES/$TOTAL"
# Check if file is safe
if [ "$POSITIVES" -eq 0 ] && [ "$SUSPICIOUS" -eq 0 ]; then
echo "✅ File is clean (no threats detected)"
exit 0
else
echo "❌ File flagged: $POSITIVES malicious, $SUSPICIOUS suspicious (out of $TOTAL scanners)"
exit 1
fi
elif [ "$RESPONSE_CODE" == "queued" ]; then
echo "File still queued for analysis..."
elif [ "$RESPONSE_CODE" == "in-progress" ]; then
echo "Analysis still in progress..."
else
echo "Unexpected response code: $RESPONSE_CODE"
echo "Response: $REPORT_RESPONSE"
exit 1
fi
ATTEMPT=$((ATTEMPT + 1))
if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then
sleep $SLEEP_INTERVAL
fi
done
echo "Timeout: Analysis did not complete within expected time"
exit 1

View File

@@ -8,4 +8,15 @@ export default {
extend: {}, extend: {},
}, },
plugins: [], plugins: [],
safelist: [
'w-[24px]', 'h-[24px]',
'w-[28px]', 'h-[28px]',
'w-[32px]', 'h-[32px]',
'w-[34px]', 'h-[34px]',
'w-[36px]', 'h-[36px]',
'w-[40px]', 'h-[40px]',
'w-[42px]', 'h-[42px]',
'w-[48px]', 'h-[48px]',
// add any other sizes you use
],
} }

0
tsconfig.json Executable file → Normal file
View File

4
types.ts Executable file → Normal file
View File

@@ -26,14 +26,12 @@ export interface Wallpaper {
export interface Config { export interface Config {
title: string; title: string;
subtitle: string; currentWallpapers: string[];
backgroundUrls: string[];
wallpaperFrequency: string; wallpaperFrequency: string;
wallpaperBlur: number; wallpaperBlur: number;
wallpaperBrightness: number; wallpaperBrightness: number;
wallpaperOpacity: number; wallpaperOpacity: number;
titleSize: string; titleSize: string;
subtitleSize: string;
alignment: string; alignment: string;
horizontalAlignment: string; horizontalAlignment: string;
clock: { clock: {

1
vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.css';

4
vite.config.ts Executable file → Normal file
View File

@@ -1,12 +1,12 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import preact from '@preact/preset-vite'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path' import { resolve } from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [preact(), tailwindcss()],
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {

BIN
waves.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB