Compare commits
13 Commits
ddf2f2a416
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 48ec764880 | |||
| babd31548c | |||
| 95ae04ecd2 | |||
| 7a137abb66 | |||
| 023b2ffdc5 | |||
| c9dafd76d1 | |||
| 254d8e26b6 | |||
| c59b7edd82 | |||
| 52d954d9ca | |||
| f35ea01c71 | |||
| 1dfbe74280 | |||
| 1580187689 | |||
| 83dcf65069 |
@@ -15,15 +15,38 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Vision Start
|
name: Build Vision Start
|
||||||
if: gitea.event_name == 'push'
|
|
||||||
runs-on: ubuntu-amd64
|
runs-on: ubuntu-amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup required tools
|
||||||
|
run: sudo apt-get install zip jq curl -y
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
run: |
|
||||||
|
bash scripts/prepare_release.sh
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
|
mv dist vision-start/
|
||||||
|
mv extension vision-start/
|
||||||
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
|
- name: Create zip archive
|
||||||
|
run: |
|
||||||
|
cd vision-start
|
||||||
|
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: release-zip
|
||||||
|
path: vision-start-${{ gitea.ref_name }}.zip
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
|
|||||||
@@ -19,19 +19,29 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup required tools
|
- name: Setup required tools
|
||||||
run: sudo apt-get install zip jq curl -y
|
run: sudo apt-get install zip jq curl -y
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
|
||||||
- name: Prepare release
|
|
||||||
run: |
|
run: |
|
||||||
bash scripts/prepare_release.sh
|
bash scripts/prepare_release.sh
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Prepare release
|
||||||
|
run: |
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: zip -r vision-start-${{ gitea.ref_name }}.zip vision-start
|
run: |
|
||||||
|
cd vision-start
|
||||||
|
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
39
AGENTS.md
@@ -14,28 +14,31 @@ Guidance for AI agents (and humans) working on Vision Start. Read before making
|
|||||||
|
|
||||||
## 1. Styling guidance
|
## 1. Styling guidance
|
||||||
|
|
||||||
The design language is **glassmorphism**: translucent black surfaces, backdrop blur, thin light borders, subtle shadows, cyan accents, and iOS-like easing.
|
The design language is **soft liquid glass**: lightly translucent matte surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan accents, and iOS-like easing.
|
||||||
|
|
||||||
### Surface recipe (use consistently)
|
### Surface recipe (use consistently)
|
||||||
- **Cards / modals / panels:** `bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl shadow-lg`
|
- **Tiles / floating controls / light surfaces:** `liquid-surface` plus `liquid-control` / `liquid-tile` / `liquid-focus` as appropriate.
|
||||||
- **Inputs:** `bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400`
|
- **Cards / modals / panels:** `liquid-panel liquid-modal-card rounded-3xl` for centered modals; `liquid-drawer` for the settings drawer.
|
||||||
- **Floating buttons (corner):** `bg-black/25 backdrop-blur-md border border-white/10 rounded-xl ... hover:bg-white/25 active:scale-90`
|
- **Inputs:** `liquid-input p-3`; ranges use `liquid-range`.
|
||||||
- **Full-screen modal overlay:** `bg-black/90 backdrop-blur-sm` (modals) or `bg-black/60 backdrop-blur-sm` (drawers)
|
- **Buttons:** `liquid-button` plus one of `liquid-button-primary`, `liquid-button-success`, `liquid-button-secondary`, or `liquid-button-danger`.
|
||||||
- **Slide-in drawer:** `bg-black/50 backdrop-blur-xl border-l border-white/10`
|
- **Add/edit surfaces:** add tiles use `liquid-ghost-tile`; tile edit controls use `liquid-edit-toolbar` and `liquid-edit-action`.
|
||||||
|
- **Full-screen modal overlay:** `liquid-modal-backdrop`.
|
||||||
|
- Keep the glass effect subtle: avoid high-opacity white highlights, heavy blur, strong saturation, or oversized glow unless a specific component needs emphasis.
|
||||||
|
|
||||||
### Color tokens
|
### Color tokens
|
||||||
- Accent / focus / selection: `cyan-400` (focus ring), `cyan-500` (active/selected background)
|
- Accent / focus / selection: cyan via `liquid-focus`, `cyan-400`, `cyan-200`, and `liquid-button-primary`
|
||||||
- Confirm/Save: `green-500` → `hover:bg-green-400`
|
- Confirm/Save: `liquid-button-success`
|
||||||
- Cancel/secondary: `gray-600` → `hover:bg-gray-500`
|
- Cancel/secondary: `liquid-button-secondary`
|
||||||
- Destructive: `red-500` → `hover:bg-red-400`
|
- Destructive: `liquid-button-danger` or red text on `liquid-edit-action`
|
||||||
- Tertiary (export/import etc.): `slate-700` → `hover:bg-slate-600`
|
- Tertiary (export/import etc.): `liquid-button-secondary`
|
||||||
- Status: online `bg-green-500`, offline `bg-red-500`, pending `bg-gray-500`
|
- Status: online `bg-green-400 text-green-400`, offline `bg-red-400 text-red-400`, pending `bg-slate-400 text-slate-400`
|
||||||
- Text: primary `text-white`, secondary `text-slate-300`/`text-slate-400`, muted `text-white/50` (in-app hover states)
|
- Text: primary `text-white`, secondary `text-slate-300`/`text-slate-400`, muted `text-white/50` (in-app hover states)
|
||||||
|
|
||||||
### Motion
|
### Motion
|
||||||
- Use the custom easing tokens defined in `index.css`: `ease-ios` (default) and `ease-spring` (toggle knobs, drawer slides).
|
- Use the custom easing tokens defined in `index.css`: `ease-ios` (default), `ease-spring` (toggle knobs, drawer slides), and `ease-liquid` (tile lift and wallpaper transitions).
|
||||||
- Standard durations: `duration-150` (buttons), `duration-200` (tiles, toggles, icons), `duration-250`/`duration-300` (modals, drawers).
|
- Standard durations: `duration-150` (buttons), `duration-200` (tiles, toggles, icons), and `duration-300` (drawers/modals).
|
||||||
- Micro-interactions are encouraged: `hover:scale-[1.04]`, `active:scale-90` / `active:scale-95` / `active:scale-[0.96]`, `hover:bg-white/25`.
|
- Prefer the shared liquid classes for hover/press motion. Add custom transforms only when the shared classes do not cover the interaction.
|
||||||
|
- Keep `prefers-reduced-motion` behavior intact when adding animations.
|
||||||
|
|
||||||
### Tailwind specifics
|
### Tailwind specifics
|
||||||
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`.
|
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`.
|
||||||
@@ -45,7 +48,7 @@ The design language is **glassmorphism**: translucent black surfaces, backdrop b
|
|||||||
### Components with established conventions to reuse
|
### Components with established conventions to reuse
|
||||||
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
|
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
|
||||||
- `ToggleSwitch` (`components/ToggleSwitch.tsx`) for boolean toggles — **always use it** instead of checkboxes.
|
- `ToggleSwitch` (`components/ToggleSwitch.tsx`) for boolean toggles — **always use it** instead of checkboxes.
|
||||||
- Modal pattern: backdrop + centered glass card (`bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl`), close on overlay click via `handleOverlayClick` (`if (e.target === e.currentTarget) onClose()`).
|
- Modal pattern: `liquid-modal-backdrop` + centered `liquid-panel liquid-modal-card rounded-3xl`, close on overlay click via `handleOverlayClick` (`if (e.target === e.currentTarget) onClose()`).
|
||||||
- New editors/settings go in `components/configuration/` as a `*Tab.tsx` and are wired into `ConfigurationModal.tsx`.
|
- New editors/settings go in `components/configuration/` as a `*Tab.tsx` and are wired into `ConfigurationModal.tsx`.
|
||||||
- New options' size presets follow the existing scale: `tiny | small | medium | large` (see `Header.tsx`, `WebsiteTile.tsx`).
|
- New options' size presets follow the existing scale: `tiny | small | medium | large` (see `Header.tsx`, `WebsiteTile.tsx`).
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ A "feature" in this project typically means: a new widget, a new settings option
|
|||||||
12. If a new field changes behavior in a way that existing users' stored data should be preserved differently, handle the migration inside `loadConfig` (and consider bumping the export `version` field in `exportConfig` if the shape changes non-additively).
|
12. If a new field changes behavior in a way that existing users' stored data should be preserved differently, handle the migration inside `loadConfig` (and consider bumping the export `version` field in `exportConfig` if the shape changes non-additively).
|
||||||
|
|
||||||
### G. Style of new tiles/widgets
|
### G. Style of new tiles/widgets
|
||||||
13. A new widget should follow the existing widget shape: optional/gated by `config.<feature>.enabled`, positioned with a glass container, and use the surface/duration/easing tokens above.
|
13. A new widget should follow the existing widget shape: optional/gated by `config.<feature>.enabled`, positioned with a liquid glass container, and use the surface/duration/easing tokens above.
|
||||||
14. New "editable" tiles/content reuse the `isEditing` mode and the edit/move/delete affordances pattern from `WebsiteTile.tsx` and `CategoryGroup.tsx`.
|
14. New "editable" tiles/content reuse the `isEditing` mode and the edit/move/delete affordances pattern from `WebsiteTile.tsx` and `CategoryGroup.tsx`.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -108,6 +111,6 @@ A "feature" in this project typically means: a new widget, a new settings option
|
|||||||
|
|
||||||
- Read `project-context.md`.
|
- Read `project-context.md`.
|
||||||
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
|
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
|
||||||
- Favor the established glass tokens (`bg-black/25`, `backdrop-blur-md`, `border-white/10`, `cyan-400/500`) over ad-hoc colors.
|
- Favor the established liquid utilities (`liquid-surface`, `liquid-panel`, `liquid-input`, `liquid-button`, `liquid-focus`) over ad-hoc glass class strings.
|
||||||
- Surface failing assumptions (CORS, missing chrome.storage, larger-than-4MB wallpapers) with clear errors, matching the style of `StorageLocalManager.ts` and `iconService.ts`.
|
- Surface failing assumptions (CORS, missing chrome.storage, larger-than-4MB wallpapers) with clear errors, matching the style of `StorageLocalManager.ts` and `iconService.ts`.
|
||||||
- Update `project-context.md` after your change.
|
- Update `project-context.md` after your change.
|
||||||
|
|||||||
190
App.tsx
@@ -1,10 +1,7 @@
|
|||||||
import { 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, 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';
|
||||||
@@ -12,6 +9,36 @@ import CategoryGroup from './components/layout/CategoryGroup';
|
|||||||
import Wallpaper from './components/Wallpaper';
|
import Wallpaper from './components/Wallpaper';
|
||||||
import { ConfigurationService } from './components/services/ConfigurationService';
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
|
|
||||||
|
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||||
|
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||||
|
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||||
|
|
||||||
|
const getAlignmentClass = (alignment: string) => {
|
||||||
|
switch (alignment) {
|
||||||
|
case 'top':
|
||||||
|
return 'justify-start';
|
||||||
|
case 'middle':
|
||||||
|
return 'justify-center';
|
||||||
|
case 'bottom':
|
||||||
|
return 'justify-end';
|
||||||
|
default:
|
||||||
|
return 'justify-center';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHorizontalAlignmentClass = (alignment: string) => {
|
||||||
|
switch (alignment) {
|
||||||
|
case 'left':
|
||||||
|
return 'justify-start';
|
||||||
|
case 'middle':
|
||||||
|
return 'justify-center';
|
||||||
|
case 'right':
|
||||||
|
return 'justify-end';
|
||||||
|
default:
|
||||||
|
return 'justify-center';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<Category[]>(() => {
|
const [categories, setCategories] = useState<Category[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -45,16 +72,16 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
const handleSaveConfig = (newConfig: Config) => {
|
const handleSaveConfig = useCallback((newConfig: Config) => {
|
||||||
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 handleNextWallpaper = () => {
|
const handleNextWallpaper = useCallback(() => {
|
||||||
const names = config.currentWallpapers;
|
const names = config.currentWallpapers;
|
||||||
if (names.length === 0) return;
|
if (names.length === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -73,9 +100,9 @@ const App: React.FC = () => {
|
|||||||
console.error('Error advancing wallpaper state', error);
|
console.error('Error advancing wallpaper state', error);
|
||||||
}
|
}
|
||||||
setWallpaperVersion(v => v + 1);
|
setWallpaperVersion(v => v + 1);
|
||||||
};
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
const handleSaveWebsite = (website: Partial<Website>) => {
|
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
|
||||||
if (editingWebsite) {
|
if (editingWebsite) {
|
||||||
const idToUpdate = website.id ?? editingWebsite.id;
|
const idToUpdate = website.id ?? editingWebsite.id;
|
||||||
const newCategories = categories.map(category => ({
|
const newCategories = categories.map(category => ({
|
||||||
@@ -102,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
|
||||||
@@ -120,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 => ({
|
||||||
@@ -131,18 +158,18 @@ 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(cat => cat.websites.some(w => w.id === website.id));
|
const categoryIndex = categories.findIndex(cat => cat.websites.some(w => w.id === website.id));
|
||||||
if (categoryIndex === -1) return;
|
if (categoryIndex === -1) return;
|
||||||
|
|
||||||
@@ -150,56 +177,22 @@ const App: React.FC = () => {
|
|||||||
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`}
|
||||||
>
|
>
|
||||||
<Wallpaper
|
<Wallpaper
|
||||||
wallpaperNames={config.currentWallpapers}
|
wallpaperNames={config.currentWallpapers}
|
||||||
@@ -214,7 +207,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<Header config={config} />
|
<Header config={config} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-8 w-full mt-16">
|
<div className="relative z-10 flex flex-col gap-7 sm:gap-8 w-full mt-12 sm:mt-14">
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<CategoryGroup
|
<CategoryGroup
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -226,7 +219,8 @@ 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 && (
|
||||||
@@ -236,12 +230,14 @@ const App: React.FC = () => {
|
|||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
setIsCategoryModalOpen(true);
|
setIsCategoryModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="text-white/50 hover:text-white active:scale-90 transition-all duration-150 ease-ios"
|
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
||||||
|
aria-label="Add category"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" className="bi bi-plus-circle" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Add category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -250,39 +246,45 @@ const App: React.FC = () => {
|
|||||||
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
||||||
|
|
||||||
{(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}
|
||||||
onNextWallpaper={handleNextWallpaper}
|
onWallpaperChange={handleWallpaperChange}
|
||||||
/>
|
onNextWallpaper={handleNextWallpaper}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ RUN npm run build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
COPY manifest.json /usr/share/nginx/html/
|
COPY manifest.json /usr/share/nginx/html/
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/gzip.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
14
README.md
@@ -1,18 +1,18 @@
|
|||||||
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
|
<div style="display: flex; justify-content: center; align-items: center; font-size: 2rem; font-weight: bold;">
|
||||||
Vision Start
|
<img src="extension/icons/vision-48.png" alt="Vision Start" width="32" height="32" style="margin-right: 1rem;"> Vision Start
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
||||||
A glassmorphism-looking like, modern and customizable startpage built with React.
|
A light, modern and customizable startpage built with React.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Vision Start is not yet available on Chrome Web Store, but it can be installed m
|
|||||||
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
|
* **Customizable Website Tiles:** Add, edit, and organize your favorite websites for quick access.
|
||||||
* **Elegant Clock:** A clock because all startpages have one.
|
* **Elegant Clock:** A clock because all startpages have one.
|
||||||
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
* **Server Status Widgets:** Monitor the status of services directly from the startpage.
|
||||||
* **Glassmorphism UI:** A modern and stylish interface with a frosted glass effect.
|
* **Liquid Glass UI:** A modern, light interface with frosted surfaces, soft highlights, and gentle motion.
|
||||||
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
||||||
* **Settings:** A settings page to configure the startpage, with export/import functionality.
|
* **Settings:** A settings page to configure the startpage, with export/import functionality.
|
||||||
|
|
||||||
|
|||||||
@@ -19,31 +19,31 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50" onClick={handleOverlayClick}>
|
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||||
<div className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-lg text-white">
|
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||||
<h2 className="text-3xl font-bold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Category Name"
|
placeholder="Category Name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center mt-8">
|
<div className="flex justify-between items-center mt-8">
|
||||||
<div>
|
<div>
|
||||||
{edit && (
|
{edit && (
|
||||||
<button onClick={onDelete} className="bg-red-500 hover:bg-red-400 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={() => onSave(name)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-250 ease-ios ${
|
className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${
|
||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -141,21 +141,21 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={`fixed top-0 right-0 h-full w-full max-w-lg bg-black/50 backdrop-blur-xl border-l border-white/10 text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
||||||
isVisible ? 'translate-x-0' : 'translate-x-full'
|
isVisible ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="p-8 flex-grow overflow-y-auto">
|
<div className="p-6 sm:p-8 flex-grow overflow-y-auto">
|
||||||
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
|
||||||
|
|
||||||
<div className="flex border-b border-white/10 mb-6">
|
<div className="liquid-surface grid grid-cols-2 sm:grid-cols-4 gap-1 rounded-2xl p-1 mb-7">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={`px-4 py-2 text-lg font-semibold ${
|
className={`liquid-focus rounded-xl px-3 py-2 text-sm font-bold transition-all duration-200 ease-ios ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'text-cyan-400 border-b-2 border-cyan-400'
|
? 'bg-cyan-400/25 text-cyan-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.28)]'
|
||||||
: 'text-slate-400'
|
: 'text-slate-300 hover:bg-white/10 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
>
|
>
|
||||||
@@ -188,18 +188,18 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 border-t border-white/10">
|
<div className="p-6 sm:p-8 border-t border-white/10">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => ConfigurationService.exportConfig()}
|
onClick={() => ConfigurationService.exportConfig()}
|
||||||
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => importInputRef.current?.click()}
|
onClick={() => importInputRef.current?.click()}
|
||||||
className="bg-slate-700 hover:bg-slate-600 active:scale-95 text-white text-sm font-semibold py-1.5 px-3 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-secondary liquid-focus text-sm py-2 px-4"
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -211,19 +211,19 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
onChange={handleImportConfig}
|
onChange={handleImportConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
isSaving.current = true;
|
isSaving.current = true;
|
||||||
onSave(config);
|
onSave(config);
|
||||||
}}
|
}}
|
||||||
className="bg-green-500 hover:bg-green-400 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5"
|
||||||
>
|
>
|
||||||
Save & Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="bg-gray-600 hover:bg-gray-500 active:scale-95 text-white font-bold py-2 px-6 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="bg-black/5 backdrop-blur-md border border-white/10 rounded-lg p-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-cyan-400 w-40 h-10 flex justify-between items-center transition-all duration-200 hover:bg-white/5"
|
className="liquid-surface liquid-focus rounded-xl px-3 text-white text-sm w-44 h-11 flex justify-between items-center transition-all duration-200 ease-ios hover:border-white/30"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@@ -95,7 +95,7 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<ul
|
<ul
|
||||||
className="absolute z-10 mt-1 w-full bg-black/70 backdrop-blur-xl border border-white/20 rounded-lg shadow-2xl overflow-hidden animate-in slide-in-from-top-2 fade-in duration-150"
|
className="liquid-panel liquid-dropdown-list absolute z-10 mt-2 w-full rounded-xl overflow-hidden"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
@@ -105,8 +105,8 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-ios flex items-center
|
className={`h-10 px-3 text-white cursor-pointer transition-all duration-150 ease-ios flex items-center
|
||||||
${
|
${
|
||||||
isSelected(option.value)
|
isSelected(option.value)
|
||||||
? 'bg-cyan-500/20 text-cyan-300'
|
? 'bg-cyan-400/20 text-cyan-200'
|
||||||
: 'hover:bg-white/20 hover:text-white hover:shadow-lg'
|
: 'hover:bg-white/20 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected(option.value)}
|
aria-selected={isSelected(option.value)}
|
||||||
@@ -117,7 +117,6 @@ const Dropdown: React.FC<DropdownProps> = ({ options, value, onChange, name, mul
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hidden input to mimic native select behavior for forms */}
|
|
||||||
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
{name && !multiple && <input type="hidden" name={name} value={value as string} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
|
||||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
|
||||||
import { getWebsiteIcon } from './utils/iconService';
|
|
||||||
import { Category, Website } from '../types';
|
|
||||||
import IconPicker from './IconPicker';
|
|
||||||
import { icons } from 'lucide-react';
|
|
||||||
|
|
||||||
interface EditModalProps {
|
|
||||||
categories: Category[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (categories: Category[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EditModal: React.FC<EditModalProps> = ({ categories, onClose, onSave }) => {
|
|
||||||
const [localCategories, setLocalCategories] = useState(categories);
|
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState(categories[0]?.id || null);
|
|
||||||
const [newCategoryName, setNewCategoryName] = useState('');
|
|
||||||
const [newWebsite, setNewWebsite] = useState({ name: '', url: '', icon: '' });
|
|
||||||
const [showIconPicker, setShowIconPicker] = useState(false);
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const handleOnDragEnd = (result: DropResult) => {
|
|
||||||
if (!result.destination) return;
|
|
||||||
|
|
||||||
const { source, destination } = result;
|
|
||||||
|
|
||||||
if (source.droppableId === destination.droppableId) {
|
|
||||||
const category = localCategories.find(cat => cat.id === source.droppableId);
|
|
||||||
if (category) {
|
|
||||||
const items = Array.from(category.websites);
|
|
||||||
const [reorderedItem] = items.splice(source.index, 1);
|
|
||||||
items.splice(destination.index, 0, reorderedItem);
|
|
||||||
const updatedCategories = localCategories.map(cat =>
|
|
||||||
cat.id === category.id ? { ...cat, websites: items } : cat
|
|
||||||
);
|
|
||||||
setLocalCategories(updatedCategories);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const sourceCategory = localCategories.find(cat => cat.id === source.droppableId);
|
|
||||||
const destCategory = localCategories.find(cat => cat.id === destination.droppableId);
|
|
||||||
if (sourceCategory && destCategory) {
|
|
||||||
const sourceItems = Array.from(sourceCategory.websites);
|
|
||||||
const [movedItem] = sourceItems.splice(source.index, 1);
|
|
||||||
const destItems = Array.from(destCategory.websites);
|
|
||||||
destItems.splice(destination.index, 0, { ...movedItem, categoryId: destCategory.id });
|
|
||||||
|
|
||||||
const updatedCategories = localCategories.map(cat => {
|
|
||||||
if (cat.id === sourceCategory.id) return { ...cat, websites: sourceItems };
|
|
||||||
if (cat.id === destCategory.id) return { ...cat, websites: destItems };
|
|
||||||
return cat;
|
|
||||||
});
|
|
||||||
setLocalCategories(updatedCategories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddCategory = () => {
|
|
||||||
if (newCategoryName.trim() === '') return;
|
|
||||||
const newCategory: Category = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: newCategoryName,
|
|
||||||
websites: [],
|
|
||||||
};
|
|
||||||
setLocalCategories([...localCategories, newCategory]);
|
|
||||||
setNewCategoryName('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCategory = (id: string) => {
|
|
||||||
const updatedCategories = localCategories.filter(cat => cat.id !== id);
|
|
||||||
setLocalCategories(updatedCategories);
|
|
||||||
if (selectedCategoryId === id) {
|
|
||||||
setSelectedCategoryId(updatedCategories[0]?.id || null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddWebsite = async () => {
|
|
||||||
if (!selectedCategoryId || !newWebsite.name || !newWebsite.url) return;
|
|
||||||
|
|
||||||
let icon = newWebsite.icon;
|
|
||||||
if (!icon || !Object.keys(icons).includes(icon)) {
|
|
||||||
icon = await getWebsiteIcon(newWebsite.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWebsiteData: Website = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: newWebsite.name,
|
|
||||||
url: newWebsite.url,
|
|
||||||
icon,
|
|
||||||
categoryId: selectedCategoryId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedCategories = localCategories.map(cat => {
|
|
||||||
if (cat.id === selectedCategoryId) {
|
|
||||||
return { ...cat, websites: [...cat.websites, newWebsiteData] };
|
|
||||||
}
|
|
||||||
return cat;
|
|
||||||
});
|
|
||||||
|
|
||||||
setLocalCategories(updatedCategories);
|
|
||||||
setNewWebsite({ name: '', url: '', icon: '' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveWebsite = (categoryId: string, websiteId: string) => {
|
|
||||||
const updatedCategories = localCategories.map(cat => {
|
|
||||||
if (cat.id === categoryId) {
|
|
||||||
return { ...cat, websites: cat.websites.filter(web => web.id !== websiteId) };
|
|
||||||
}
|
|
||||||
return cat;
|
|
||||||
});
|
|
||||||
setLocalCategories(updatedCategories);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedCategory = localCategories.find(cat => cat.id === selectedCategoryId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center z-50">
|
|
||||||
<div ref={modalRef} className="bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl p-8 w-full max-w-4xl text-white">
|
|
||||||
<h2 className="text-3xl font-bold mb-6">Edit Bookmarks</h2>
|
|
||||||
<div className="grid grid-cols-3 gap-8">
|
|
||||||
<div className="col-span-1">
|
|
||||||
<h3 className="text-xl font-semibold mb-4">Categories</h3>
|
|
||||||
<div className="flex flex-col gap-2 mb-4">
|
|
||||||
{localCategories.map(category => (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
className={`flex justify-between items-center p-3 rounded-lg cursor-pointer ${selectedCategoryId === category.id ? 'bg-cyan-500/50' : 'bg-white/10'}`}
|
|
||||||
onClick={() => setSelectedCategoryId(category.id)}
|
|
||||||
>
|
|
||||||
<span>{category.name}</span>
|
|
||||||
<button onClick={() => handleRemoveCategory(category.id)} className="text-red-500 hover:text-red-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="New Category"
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<button onClick={handleAddCategory} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<h3 className="text-xl font-semibold mb-4">Websites</h3>
|
|
||||||
{selectedCategory && (
|
|
||||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
|
||||||
<Droppable droppableId={selectedCategory.id}>
|
|
||||||
{(provided) => (
|
|
||||||
<ul {...provided.droppableProps} ref={provided.innerRef} className="mb-8">
|
|
||||||
{selectedCategory.websites.map((website, index) => (
|
|
||||||
<Draggable key={website.id} draggableId={website.id} index={index}>
|
|
||||||
{(provided) => (
|
|
||||||
<li
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
className="flex items-center justify-between bg-white/10 p-3 rounded-lg mb-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{Object.keys(icons).includes(website.icon) ? (
|
|
||||||
React.createElement(icons[website.icon as keyof typeof icons], { className: "h-8 w-8 mr-4" })
|
|
||||||
) : (
|
|
||||||
<img src={website.icon} alt={website.name} className="h-8 w-8 mr-4" />
|
|
||||||
)}
|
|
||||||
<span>{website.name}</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => handleRemoveWebsite(selectedCategory.id, website.id)} className="text-red-500 hover:text-red-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold mb-4">Add New Bookmark</h3>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Name"
|
|
||||||
value={newWebsite.name}
|
|
||||||
onChange={(e) => setNewWebsite({ ...newWebsite, name: e.target.value })}
|
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="URL"
|
|
||||||
value={newWebsite.url}
|
|
||||||
onChange={(e) => setNewWebsite({ ...newWebsite, url: e.target.value })}
|
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Icon URL or name"
|
|
||||||
value={newWebsite.icon}
|
|
||||||
onChange={(e) => setNewWebsite({ ...newWebsite, icon: e.target.value })}
|
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
|
||||||
/>
|
|
||||||
<button onClick={() => setShowIconPicker(!showIconPicker)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-4 rounded-lg">
|
|
||||||
{showIconPicker ? 'Close' : 'Select Icon'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{showIconPicker && (
|
|
||||||
<IconPicker
|
|
||||||
onSelect={(iconName) => {
|
|
||||||
setNewWebsite({ ...newWebsite, icon: iconName });
|
|
||||||
setShowIconPicker(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button onClick={handleAddWebsite} className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-3 px-4 rounded-lg">
|
|
||||||
Add Bookmark
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-4 mt-8">
|
|
||||||
<button onClick={() => onSave(localCategories)} className="bg-green-500 hover:bg-green-400 text-white font-bold py-2 px-6 rounded-lg">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-6 rounded-lg">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditModal;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { 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>
|
||||||
|
|||||||
@@ -11,14 +11,20 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className={`w-14 h-8 flex items-center rounded-full p-1 cursor-pointer transition-colors duration-200 ease-ios ${checked ? 'bg-cyan-500' : 'bg-gray-600'}`}
|
type="button"
|
||||||
|
className={`liquid-focus relative w-14 h-8 flex items-center rounded-full p-1 cursor-pointer border transition-all duration-200 ease-ios ${
|
||||||
|
checked
|
||||||
|
? 'bg-cyan-400/70 border-cyan-200/60 shadow-[0_0_24px_rgba(34,211,238,0.22)]'
|
||||||
|
: 'bg-white/10 border-white/20'
|
||||||
|
}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
|
aria-pressed={checked}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`bg-white w-6 h-6 rounded-full shadow-md transform transition-transform duration-200 ease-spring ${checked ? 'translate-x-6' : 'translate-x-0'}`}
|
className={`bg-white w-6 h-6 rounded-full shadow-lg transform transition-transform duration-200 ease-spring ${checked ? 'translate-x-6' : 'translate-x-0'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { Wallpaper as WallpaperType } from '../types';
|
import { Wallpaper as WallpaperType } from '../types';
|
||||||
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
import { getWallpaperFromChromeStorageLocal } from './utils/StorageLocalManager';
|
||||||
@@ -13,50 +13,61 @@ interface WallpaperProps {
|
|||||||
wallpaperVersion: number;
|
wallpaperVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
|
||||||
|
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
|
||||||
|
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const parseFrequencyToMs = (freq: string): number => {
|
||||||
|
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||||
|
const match = freq.match(/^(\d+)(h|d)$/);
|
||||||
|
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2];
|
||||||
|
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
||||||
|
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
||||||
|
};
|
||||||
|
|
||||||
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
if (!name) return 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);
|
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
if (foundInBase) {
|
if (foundInBase) {
|
||||||
return foundInBase.url || foundInBase.base64;
|
resolved = foundInBase.url || foundInBase.base64;
|
||||||
}
|
} else {
|
||||||
|
try {
|
||||||
try {
|
const storedUserWallpapers: WallpaperType[] =
|
||||||
const storedUserWallpapers: WallpaperType[] =
|
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||||
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
if (foundInUser) {
|
||||||
if (foundInUser) {
|
try {
|
||||||
try {
|
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
||||||
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
if (wallpaperData && wallpaperData.startsWith('http')) {
|
||||||
if (wallpaperData && wallpaperData.startsWith('http')) {
|
resolved = wallpaperData;
|
||||||
return wallpaperData;
|
} else {
|
||||||
|
resolved = wallpaperData || undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting wallpaper from chrome storage', error);
|
||||||
|
resolved = undefined;
|
||||||
}
|
}
|
||||||
return wallpaperData || undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting wallpaper from chrome storage', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading userWallpapers from localStorage', error);
|
||||||
|
resolved = undefined;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading userWallpapers from localStorage', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
wallpaperUrlCache.set(name, resolved);
|
||||||
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
|
const resolvedRef = useRef(false);
|
||||||
// Helper to parse wallpaperFrequency string to ms
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateWallpaper = async () => {
|
const updateWallpaper = async () => {
|
||||||
@@ -111,6 +122,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
resolvedRef.current = true;
|
||||||
setImageUrl(resolvedUrl);
|
setImageUrl(resolvedUrl);
|
||||||
};
|
};
|
||||||
updateWallpaper();
|
updateWallpaper();
|
||||||
@@ -119,18 +131,18 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="fixed inset-0 -z-10 w-full h-full"
|
<div
|
||||||
style={{
|
className="wallpaper-layer wallpaper-transition"
|
||||||
backgroundImage: `url(${imageUrl})`,
|
style={{
|
||||||
backgroundSize: 'cover',
|
backgroundImage: `url(${imageUrl})`,
|
||||||
backgroundPosition: 'center',
|
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||||
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
opacity: opacity / 100,
|
||||||
opacity: opacity / 100,
|
}}
|
||||||
transition: 'filter 0.3s, opacity 0.3s',
|
aria-label="Wallpaper background"
|
||||||
}}
|
/>
|
||||||
aria-label="Wallpaper background"
|
<div className="wallpaper-luminance" aria-hidden="true" />
|
||||||
/>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { 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([]);
|
||||||
}
|
}
|
||||||
@@ -76,7 +93,6 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
console.log({ id: website?.id, name, url, icon });
|
|
||||||
onSave({ id: website?.id, name, url, icon });
|
onSave({ id: website?.id, name, url, icon });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,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>
|
||||||
@@ -109,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"
|
||||||
@@ -128,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}
|
||||||
@@ -140,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}`}
|
||||||
@@ -153,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>
|
||||||
@@ -161,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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
@@ -23,7 +23,6 @@ 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':
|
||||||
@@ -37,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':
|
||||||
@@ -61,53 +59,48 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Simulate loading time (dev purpose)
|
|
||||||
// e.preventDefault();
|
|
||||||
// setTimeout(() => {
|
|
||||||
// setIsLoading(false);
|
|
||||||
// }, 3500); // Small delay to show spinner before navigation
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
const iconSizeClass = `w-[${getIconPixelSize(tileSize)}px] h-[${getIconPixelSize(tileSize)}px]`;
|
||||||
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
const iconSizeLoadingClass = `w-[${getIconLoadingPixelSize(tileSize)}px] h-[${getIconLoadingPixelSize(tileSize)}px]`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios`}>
|
<div className={`relative ${getTileSizeClass(tileSize)} transition-all duration-200 ease-ios ${isEditing ? 'mb-4' : ''}`}>
|
||||||
<a
|
<a
|
||||||
href={isEditing ? undefined : website.url}
|
href={isEditing ? undefined : website.url}
|
||||||
target="_self"
|
target="_self"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className="group flex flex-col items-center justify-center p-4 bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl w-full h-full transform transition-all duration-200 ease-ios hover:scale-[1.04] active:scale-[0.96] hover:bg-white/25 shadow-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:ring-opacity-75"
|
className={`liquid-surface liquid-tile liquid-focus group flex flex-col items-center justify-center w-full h-full p-4 ${isEditing ? 'pb-6' : ''}`}
|
||||||
|
aria-label={isEditing ? `${website.name} edit controls` : `Open ${website.name}`}
|
||||||
>
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center mb-6">
|
<div className="absolute inset-0 z-10 flex items-center justify-center mb-6">
|
||||||
<svg className="animate-spin h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg className="animate-spin h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`flex items-center transition-all duration-200 ease-ios ${isLoading ? 'mt-18' : 'flex-col'} ${isLoading ? 'gap-2' : ''}`}>
|
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
|
||||||
<div className={`transition-all duration-200 ease-ios ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
|
||||||
<img src={website.icon} alt={`${website.name} icon`} className={`object-contain w-full h-full`} />
|
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-slate-100 font-medium text-base tracking-wide text-center transition-all duration-200 ease-ios ${isLoading ? 'text-sm' : ''}`}>
|
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
|
||||||
{website.name}
|
{website.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-2">
|
<div className="liquid-surface liquid-edit-toolbar">
|
||||||
<button onClick={() => onMove(website, 'left')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-left" viewBox="0 0 16 16">
|
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||||
</svg></button>
|
</svg></button>
|
||||||
<button onClick={() => onEdit(website)} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
</svg></button>
|
</svg></button>
|
||||||
<button onClick={() => onMove(website, 'right')} className="text-white/50 hover:text-white transition-colors"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-arrow-right" viewBox="0 0 16 16">
|
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||||
</svg></button>
|
</svg></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -115,4 +108,4 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsiteTile;
|
export default memo(WebsiteTile);
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
|
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={config.clock.enabled}
|
checked={config.clock.enabled}
|
||||||
onChange={(checked) => updateClock({ enabled: checked })}
|
onChange={(checked) => updateClock({ enabled: checked })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="clock.size"
|
name="clock.size"
|
||||||
@@ -36,7 +36,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="clock.font"
|
name="clock.font"
|
||||||
@@ -50,7 +50,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="clock.format"
|
name="clock.format"
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={config.title}
|
value={config.title}
|
||||||
onChange={(e) => onChange({ title: e.target.value })}
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="titleSize"
|
name="titleSize"
|
||||||
@@ -33,7 +33,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="alignment"
|
name="alignment"
|
||||||
@@ -46,7 +46,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="tileSize"
|
name="tileSize"
|
||||||
@@ -59,7 +59,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="horizontalAlignment"
|
name="horizontalAlignment"
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ interface ServerWidgetTabProps {
|
|||||||
onChange: (updates: Partial<Config>) => void;
|
onChange: (updates: Partial<Config>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||||
|
|
||||||
|
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||||
|
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||||
|
return { '--range-progress': `${progress}%` };
|
||||||
|
};
|
||||||
|
|
||||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
const [newServerName, setNewServerName] = useState('');
|
const [newServerName, setNewServerName] = useState('');
|
||||||
const [newServerAddress, setNewServerAddress] = useState('');
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
@@ -44,7 +51,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
|
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={config.serverWidget.enabled}
|
checked={config.serverWidget.enabled}
|
||||||
@@ -53,7 +60,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
{config.serverWidget.enabled && (
|
{config.serverWidget.enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -62,9 +69,10 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
max="60"
|
max="60"
|
||||||
value={config.serverWidget.pingFrequency}
|
value={config.serverWidget.pingFrequency}
|
||||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
||||||
/>
|
/>
|
||||||
<span>{config.serverWidget.pingFrequency}s</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -84,7 +92,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.draggableProps}
|
{...provided.draggableProps}
|
||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
className="liquid-surface flex items-center justify-between rounded-xl p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{server.name}</p>
|
<p className="font-semibold">{server.name}</p>
|
||||||
@@ -92,7 +100,8 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveServer(server.id)}
|
onClick={() => handleRemoveServer(server.id)}
|
||||||
className="text-red-500 hover:text-red-400"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
|
aria-label={`Remove ${server.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -118,24 +127,24 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
)}
|
)}
|
||||||
</Droppable>
|
</Droppable>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex flex-col gap-2 mt-3 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Server Name"
|
placeholder="Server Name"
|
||||||
value={newServerName}
|
value={newServerName}
|
||||||
onChange={(e) => setNewServerName(e.target.value)}
|
onChange={(e) => setNewServerName(e.target.value)}
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="HTTP Address"
|
placeholder="HTTP Address"
|
||||||
value={newServerAddress}
|
value={newServerAddress}
|
||||||
onChange={(e) => setNewServerAddress(e.target.value)}
|
onChange={(e) => setNewServerAddress(e.target.value)}
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddServer}
|
onClick={handleAddServer}
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,31 @@ interface ThemeTabProps {
|
|||||||
onNextWallpaper: () => void;
|
onNextWallpaper: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||||
|
|
||||||
|
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||||
|
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||||
|
return { '--range-progress': `${progress}%` };
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||||
|
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||||
|
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||||
|
|
||||||
|
const clampWallpaperFrequencyHours = (hours: number): number =>
|
||||||
|
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
|
||||||
|
|
||||||
|
const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||||
|
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||||
|
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||||
|
const value = Number(match[1]);
|
||||||
|
const hours = match[2] === 'd' ? value * 24 : value;
|
||||||
|
return clampWallpaperFrequencyHours(hours);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatWallpaperFrequency = (hours: number): string =>
|
||||||
|
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
|
||||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -28,6 +53,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||||
|
|
||||||
const handleAddWallpaper = async () => {
|
const handleAddWallpaper = async () => {
|
||||||
if (newWallpaperUrl.trim() === '') return;
|
if (newWallpaperUrl.trim() === '') return;
|
||||||
@@ -55,7 +81,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
name="currentWallpapers"
|
name="currentWallpapers"
|
||||||
@@ -66,24 +92,30 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
||||||
<Dropdown
|
<div className="flex items-center gap-4">
|
||||||
name="wallpaperFrequency"
|
<input
|
||||||
value={config.wallpaperFrequency}
|
type="range"
|
||||||
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
|
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||||
options={[
|
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||||
{ value: '1h', label: '1 hour' },
|
step="1"
|
||||||
{ value: '3h', label: '3 hours' },
|
value={wallpaperFrequencyHours}
|
||||||
{ value: '6h', label: '6 hours' },
|
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
||||||
{ value: '12h', label: '12 hours' },
|
className="liquid-range"
|
||||||
{ value: '1d', label: '1 day' },
|
style={getRangeStyle(
|
||||||
{ value: '2d', label: '2 days' },
|
wallpaperFrequencyHours,
|
||||||
]}
|
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||||
/>
|
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="w-20 text-right text-sm text-slate-200">
|
||||||
|
{formatWallpaperFrequency(wallpaperFrequencyHours)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -92,12 +124,13 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
max="50"
|
max="50"
|
||||||
value={config.wallpaperBlur}
|
value={config.wallpaperBlur}
|
||||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
||||||
/>
|
/>
|
||||||
<span>{config.wallpaperBlur}px</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -106,12 +139,13 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
max="200"
|
max="200"
|
||||||
value={config.wallpaperBrightness}
|
value={config.wallpaperBrightness}
|
||||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
||||||
/>
|
/>
|
||||||
<span>{config.wallpaperBrightness}%</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
@@ -120,9 +154,10 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
max="100"
|
max="100"
|
||||||
value={config.wallpaperOpacity}
|
value={config.wallpaperOpacity}
|
||||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||||
className="w-48"
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
||||||
/>
|
/>
|
||||||
<span>{config.wallpaperOpacity}%</span>
|
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{chromeStorageAvailable && (
|
{chromeStorageAvailable && (
|
||||||
@@ -133,12 +168,13 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
{userWallpapers.map((wallpaper) => (
|
{userWallpapers.map((wallpaper) => (
|
||||||
<div
|
<div
|
||||||
key={wallpaper.name}
|
key={wallpaper.name}
|
||||||
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
className="liquid-surface flex items-center justify-between rounded-xl p-2.5"
|
||||||
>
|
>
|
||||||
<span className="truncate">{wallpaper.name}</span>
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteWallpaper(wallpaper)}
|
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||||
className="text-red-500 hover:text-red-400"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
|
aria-label={`Delete ${wallpaper.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -167,19 +203,19 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
placeholder="Wallpaper Name (optional for URLs)"
|
placeholder="Wallpaper Name (optional for URLs)"
|
||||||
value={newWallpaperName}
|
value={newWallpaperName}
|
||||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
onChange={(e) => setNewWallpaperName(e.target.value)}
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Image URL"
|
placeholder="Image URL"
|
||||||
value={newWallpaperUrl}
|
value={newWallpaperUrl}
|
||||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
className="liquid-input p-2.5"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddWallpaper}
|
onClick={handleAddWallpaper}
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 active:scale-95 text-white font-bold py-2 px-4 rounded-lg transition-all duration-150 ease-ios"
|
className="liquid-button liquid-button-primary liquid-focus py-2.5 px-4"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -187,7 +223,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
<div className="flex items-center justify-center w-full">
|
<div className="flex items-center justify-center w-full">
|
||||||
<label
|
<label
|
||||||
htmlFor="file-upload"
|
htmlFor="file-upload"
|
||||||
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
|
className="liquid-surface liquid-ghost-tile flex flex-col items-center justify-center w-full h-32 cursor-pointer transition-all duration-200 ease-ios"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
<svg
|
<svg
|
||||||
@@ -227,7 +263,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={onNextWallpaper}
|
onClick={onNextWallpaper}
|
||||||
disabled={config.currentWallpapers.length === 0}
|
disabled={config.currentWallpapers.length === 0}
|
||||||
className="flex items-center gap-2 bg-black/25 backdrop-blur-md border border-white/10 hover:bg-white/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-xl transition-all duration-150 ease-ios"
|
className="liquid-surface liquid-control liquid-focus disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold py-2 px-4 rounded-2xl"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
import WebsiteTile from '../WebsiteTile';
|
import WebsiteTile from '../WebsiteTile';
|
||||||
import { Category, Website } from '../../types';
|
import { Category, Website } from '../../types';
|
||||||
|
|
||||||
@@ -10,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,
|
||||||
@@ -25,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}
|
||||||
@@ -53,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>
|
||||||
@@ -72,4 +88,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CategoryGroup;
|
export default memo(CategoryGroup);
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ interface ConfigurationButtonProps {
|
|||||||
|
|
||||||
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
|
const ConfigurationButton: React.FC<ConfigurationButtonProps> = ({ onClick }) => {
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 right-4">
|
<div className="absolute top-4 right-4 z-20">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 active:scale-90 transition-all duration-200 ease-ios"
|
className="liquid-surface liquid-control liquid-focus rounded-2xl p-3"
|
||||||
|
aria-label="Open configuration"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||||
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09c.7 0 1.31-.4 1.51-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.51 1.31.61 1.82.33.51-.28 1-.81 1-1.51V3a2 2 0 1 1 4 0v.09c0 .7.49 1.23 1 1.51.51.28 1.31.18 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82c.2.6.81 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.7 0-1.31.4-1.51 1z"/>
|
<path stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09c.7 0 1.31-.4 1.51-1a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06c.51.51 1.31.61 1.82.33.51-.28 1-.81 1-1.51V3a2 2 0 1 1 4 0v.09c0 .7.49 1.23 1 1.51.51.28 1.31.18 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82c.2.6.81 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.7 0-1.31.4-1.51 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ interface EditButtonProps {
|
|||||||
|
|
||||||
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-4 left-4">
|
<div className="absolute top-4 left-4 z-20">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="bg-black/25 backdrop-blur-md border border-white/10 rounded-xl p-3 text-white flex items-center gap-2 hover:bg-white/25 active:scale-90 transition-all duration-200 ease-ios"
|
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||||
style={{ fontSize: '12px' }}
|
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-pencil" viewBox="0 0 16 16">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{isEditing ? 'Done' : ''}
|
{isEditing ? 'Done' : ''}
|
||||||
|
|||||||
@@ -50,20 +50,21 @@ 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.title && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1
|
<h1
|
||||||
className={`${getTitleSizeClass(config.titleSize)} font-extrabold text-white tracking-tighter mb-3 mt-4`}
|
className={`liquid-title-text ${getTitleSizeClass(config.titleSize)} font-extrabold text-white mb-2 mt-3`}
|
||||||
style={{ textShadow: '0 2px 4px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
>
|
||||||
{config.title}
|
{config.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
|||||||
|
|
||||||
export const DEFAULT_CONFIG: Config = {
|
export const DEFAULT_CONFIG: Config = {
|
||||||
title: 'Vision Start',
|
title: 'Vision Start',
|
||||||
currentWallpapers: ['Abstract'],
|
currentWallpapers: ['Beach'],
|
||||||
wallpaperFrequency: '1d',
|
wallpaperFrequency: '1d',
|
||||||
wallpaperBlur: 0,
|
wallpaperBlur: 0,
|
||||||
wallpaperBrightness: 100,
|
wallpaperBrightness: 108,
|
||||||
wallpaperOpacity: 100,
|
wallpaperOpacity: 96,
|
||||||
titleSize: 'medium',
|
titleSize: 'medium',
|
||||||
alignment: 'middle',
|
alignment: 'middle',
|
||||||
horizontalAlignment: 'middle',
|
horizontalAlignment: 'middle',
|
||||||
@@ -43,13 +43,37 @@ const safeParse = (value: string | null): unknown => {
|
|||||||
const toStorageString = (value: unknown): string =>
|
const toStorageString = (value: unknown): string =>
|
||||||
typeof value === 'string' ? value : JSON.stringify(value);
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
const deepMerge = <T>(base: T, stored: unknown): T => {
|
||||||
|
if (isPlainObject(base)) {
|
||||||
|
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||||
|
const storedObj = isPlainObject(stored) ? stored : {};
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(storedObj, key)) {
|
||||||
|
result[key] = deepMerge(result[key], storedObj[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
if (Array.isArray(base)) {
|
||||||
|
return (Array.isArray(stored) ? stored : base) as T;
|
||||||
|
}
|
||||||
|
if (stored === null) return base;
|
||||||
|
return typeof stored === typeof base ? (stored as T) : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeConfig = (stored: unknown): Config =>
|
||||||
|
deepMerge(DEFAULT_CONFIG, stored);
|
||||||
|
|
||||||
export const ConfigurationService = {
|
export const ConfigurationService = {
|
||||||
loadConfig(): Config {
|
loadConfig(): Config {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('config');
|
const stored = localStorage.getItem('config');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
return { ...DEFAULT_CONFIG, ...parsed };
|
return normalizeConfig(parsed);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing config from localStorage', error);
|
console.error('Error parsing config from localStorage', error);
|
||||||
@@ -159,12 +183,12 @@ export const ConfigurationService = {
|
|||||||
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
|
const importedConfig = (localStorageData as Record<string, unknown>).config;
|
||||||
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||||
.userWallpapers as Wallpaper[];
|
.userWallpapers;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: importedConfig || { ...DEFAULT_CONFIG },
|
config: normalizeConfig(importedConfig),
|
||||||
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,25 @@
|
|||||||
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +32,6 @@ function ping(url, multiplier) {
|
|||||||
resolve(delta);
|
resolve(delta);
|
||||||
};
|
};
|
||||||
request_image(url).then(response).catch(response);
|
request_image(url).then(response).catch(response);
|
||||||
|
|
||||||
setTimeout(function() { reject(Error('Timeout')); }, 5000);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
extension/icons/vision-128.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
extension/icons/vision-16.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
extension/icons/vision-48.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
543
index.css
@@ -3,4 +3,547 @@
|
|||||||
@theme {
|
@theme {
|
||||||
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
--ease-ios: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--ease-liquid: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: #0f1720;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
background: #0f1720;
|
||||||
|
color: white;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vision-shell {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.06), transparent 28rem),
|
||||||
|
radial-gradient(circle at 82% 20%, rgba(34, 211, 238, 0.05), transparent 26rem),
|
||||||
|
linear-gradient(135deg, rgba(241, 245, 249, 0.04), rgba(15, 23, 42, 0.16) 52%, rgba(8, 13, 20, 0.32));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vision-shell::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -15;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.02) 38%, rgba(2, 6, 23, 0.3) 100%),
|
||||||
|
radial-gradient(ellipse at center, transparent 0%, rgba(2, 6, 23, 0.18) 78%, rgba(2, 6, 23, 0.36) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-transition {
|
||||||
|
transition: filter 0.7s var(--ease-liquid), opacity 0.7s var(--ease-liquid), transform 0.7s var(--ease-liquid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -30;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
transform: scale(1.015);
|
||||||
|
will-change: filter, opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper-luminance {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -25;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02) 34%, rgba(5, 12, 20, 0.34)),
|
||||||
|
radial-gradient(circle at 50% 38%, rgba(255, 255, 255, 0.05), transparent 28rem),
|
||||||
|
radial-gradient(circle at 50% 100%, rgba(8, 13, 24, 0.36), transparent 36rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-surface {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.04) 46%, rgba(15, 23, 42, 0.18)),
|
||||||
|
rgba(8, 13, 23, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||||
|
inset 0 -10px 20px rgba(2, 6, 23, 0.12),
|
||||||
|
0 12px 28px rgba(2, 6, 23, 0.2);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(112%);
|
||||||
|
backdrop-filter: blur(10px) saturate(112%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-surface::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(255, 255, 255, 0.1), transparent 34%),
|
||||||
|
radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.07), transparent 42%);
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-panel {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04) 48%, rgba(5, 12, 22, 0.34)),
|
||||||
|
rgba(8, 13, 23, 0.48);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||||
|
0 22px 56px rgba(2, 6, 23, 0.3);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(115%);
|
||||||
|
backdrop-filter: blur(14px) saturate(115%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
color: white;
|
||||||
|
transition:
|
||||||
|
transform 180ms var(--ease-ios),
|
||||||
|
background 180ms var(--ease-ios),
|
||||||
|
border-color 180ms var(--ease-ios),
|
||||||
|
box-shadow 180ms var(--ease-ios),
|
||||||
|
color 180ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-control:hover {
|
||||||
|
transform: translateY(-1px) scale(1.025);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)),
|
||||||
|
rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||||
|
0 14px 30px rgba(2, 6, 23, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-control:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-focus:focus-visible {
|
||||||
|
outline: 2px solid rgba(34, 211, 238, 0.9);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-input {
|
||||||
|
width: 100%;
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.075);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
transition:
|
||||||
|
background 180ms var(--ease-ios),
|
||||||
|
border-color 180ms var(--ease-ios),
|
||||||
|
box-shadow 180ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-input::placeholder {
|
||||||
|
color: rgba(226, 232, 240, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-input:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(34, 211, 238, 0.76);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(34, 211, 238, 0.16),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
transition:
|
||||||
|
transform 150ms var(--ease-ios),
|
||||||
|
filter 150ms var(--ease-ios),
|
||||||
|
background 150ms var(--ease-ios),
|
||||||
|
box-shadow 150ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button-primary {
|
||||||
|
background: linear-gradient(135deg, #06b6d4, #22d3ee);
|
||||||
|
box-shadow: 0 14px 28px rgba(8, 145, 178, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button-success {
|
||||||
|
background: linear-gradient(135deg, #10b981, #22c55e);
|
||||||
|
box-shadow: 0 14px 28px rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button-secondary {
|
||||||
|
background: rgba(100, 116, 139, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-button-danger {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #fb7185);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range {
|
||||||
|
--range-progress: 50%;
|
||||||
|
width: clamp(10rem, 36vw, 13.5rem);
|
||||||
|
height: 1.6rem;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
accent-color: #22d3ee;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range::-webkit-slider-runnable-track {
|
||||||
|
height: 0.68rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78)) 0 / var(--range-progress) 100% no-repeat,
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
|
||||||
|
rgba(8, 13, 23, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||||
|
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
|
||||||
|
0 8px 18px rgba(2, 6, 23, 0.14);
|
||||||
|
transition:
|
||||||
|
border-color 180ms var(--ease-ios),
|
||||||
|
box-shadow 180ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:hover::-webkit-slider-runnable-track {
|
||||||
|
border-color: rgba(255, 255, 255, 0.24);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||||
|
inset 0 -1px 2px rgba(2, 6, 23, 0.2),
|
||||||
|
0 10px 22px rgba(2, 6, 23, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range::-webkit-slider-thumb {
|
||||||
|
width: 1.18rem;
|
||||||
|
height: 1.18rem;
|
||||||
|
margin-top: -0.31rem;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(34, 211, 238, 0.1),
|
||||||
|
0 8px 18px rgba(2, 6, 23, 0.28),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||||
|
transition:
|
||||||
|
transform 180ms var(--ease-ios),
|
||||||
|
box-shadow 180ms var(--ease-ios),
|
||||||
|
border-color 180ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:hover::-webkit-slider-thumb {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 5px rgba(34, 211, 238, 0.15),
|
||||||
|
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:active::-webkit-slider-thumb {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:focus-visible::-webkit-slider-thumb {
|
||||||
|
border-color: rgba(34, 211, 238, 0.9);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(34, 211, 238, 0.24),
|
||||||
|
0 0 0 7px rgba(255, 255, 255, 0.08),
|
||||||
|
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range::-moz-range-track {
|
||||||
|
height: 0.68rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.13), rgba(255, 255, 255, 0.06)),
|
||||||
|
rgba(8, 13, 23, 0.4);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.15),
|
||||||
|
inset 0 -1px 2px rgba(2, 6, 23, 0.24),
|
||||||
|
0 8px 18px rgba(2, 6, 23, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range::-moz-range-progress {
|
||||||
|
height: 0.68rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, rgba(34, 211, 238, 0.92), rgba(103, 232, 249, 0.78));
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range::-moz-range-thumb {
|
||||||
|
width: 1.18rem;
|
||||||
|
height: 1.18rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(145deg, #ffffff, #e5fbff 48%, #a5f3fc);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(34, 211, 238, 0.1),
|
||||||
|
0 8px 18px rgba(2, 6, 23, 0.28),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||||
|
transition:
|
||||||
|
transform 180ms var(--ease-ios),
|
||||||
|
box-shadow 180ms var(--ease-ios),
|
||||||
|
border-color 180ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:hover::-moz-range-thumb {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 5px rgba(34, 211, 238, 0.15),
|
||||||
|
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:active::-moz-range-thumb {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range:focus-visible::-moz-range-thumb {
|
||||||
|
border-color: rgba(34, 211, 238, 0.9);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(34, 211, 238, 0.24),
|
||||||
|
0 0 0 7px rgba(255, 255, 255, 0.08),
|
||||||
|
0 10px 22px rgba(2, 6, 23, 0.32),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-tile {
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
transition:
|
||||||
|
transform 240ms var(--ease-liquid),
|
||||||
|
background 260ms var(--ease-ios),
|
||||||
|
border-color 260ms var(--ease-ios),
|
||||||
|
box-shadow 340ms var(--ease-liquid);
|
||||||
|
will-change: transform, box-shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-tile:hover {
|
||||||
|
transform: translateY(-3px) scale(1.025);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08) 46%, rgba(34, 211, 238, 0.08)),
|
||||||
|
rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.24);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||||
|
0 18px 34px rgba(2, 6, 23, 0.24),
|
||||||
|
0 0 20px rgba(34, 211, 238, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-tile:active {
|
||||||
|
transform: scale(0.965);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-ghost-tile {
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: rgba(255, 255, 255, 0.32);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)),
|
||||||
|
rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-ghost-tile:hover {
|
||||||
|
color: white;
|
||||||
|
border-color: rgba(34, 211, 238, 0.62);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(34, 211, 238, 0.08), rgba(255, 255, 255, 0.04)),
|
||||||
|
rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-edit-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -0.9rem;
|
||||||
|
z-index: 5;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-edit-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
transition:
|
||||||
|
color 150ms var(--ease-ios),
|
||||||
|
background 150ms var(--ease-ios),
|
||||||
|
transform 150ms var(--ease-ios);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-edit-action:hover {
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-edit-action:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-category-title {
|
||||||
|
text-shadow: 0 2px 16px rgba(2, 6, 23, 0.45);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-clock-text,
|
||||||
|
.liquid-title-text {
|
||||||
|
text-shadow:
|
||||||
|
0 2px 18px rgba(2, 6, 23, 0.45),
|
||||||
|
0 0 28px rgba(255, 255, 255, 0.12);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-drawer {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03) 38%, rgba(7, 12, 22, 0.44)),
|
||||||
|
rgba(7, 12, 22, 0.68);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 0 0 rgba(255, 255, 255, 0.08),
|
||||||
|
-26px 0 64px rgba(2, 6, 23, 0.3);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(112%);
|
||||||
|
backdrop-filter: blur(16px) saturate(112%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-modal-backdrop {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 30%, rgba(255, 255, 255, 0.04), transparent 32rem),
|
||||||
|
rgba(3, 7, 18, 0.72);
|
||||||
|
-webkit-backdrop-filter: blur(6px) saturate(108%);
|
||||||
|
backdrop-filter: blur(6px) saturate(108%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-modal-card {
|
||||||
|
overflow: visible;
|
||||||
|
animation: liquid-pop 240ms var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-dropdown-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: liquid-drop 150ms var(--ease-ios);
|
||||||
|
transform-origin: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-status-dot {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px rgba(255, 255, 255, 0.1),
|
||||||
|
0 0 18px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes liquid-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.75rem) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes liquid-drop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.35rem) scaleY(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-control:hover,
|
||||||
|
.liquid-tile:hover,
|
||||||
|
.liquid-button:hover,
|
||||||
|
.liquid-edit-action:hover,
|
||||||
|
.liquid-range:hover::-webkit-slider-thumb,
|
||||||
|
.liquid-range:active::-webkit-slider-thumb,
|
||||||
|
.liquid-range:hover::-moz-range-thumb,
|
||||||
|
.liquid-range:active::-moz-range-thumb {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquid-range::-webkit-slider-runnable-track,
|
||||||
|
.liquid-range::-webkit-slider-thumb,
|
||||||
|
.liquid-range::-moz-range-track,
|
||||||
|
.liquid-range::-moz-range-progress,
|
||||||
|
.liquid-range::-moz-range-thumb {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-black">
|
<body class="bg-slate-950">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/index.tsx"></script>
|
<script type="module" src="/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Vision Startpage",
|
"name": "Vision Startpage",
|
||||||
"version": "1.0",
|
"version": "1.0.0",
|
||||||
"description": "A beautiful and customizable startpage for your browser.",
|
"description": "A light, modern and customizable startpage.",
|
||||||
"chrome_url_overrides": {
|
"chrome_url_overrides": {
|
||||||
"newtab": "index.html"
|
"newtab": "index.html"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
|
"icons": {
|
||||||
|
"16": "extension/icons/vision-16.png",
|
||||||
|
"48": "extension/icons/vision-48.png",
|
||||||
|
"128": "extension/icons/vision-128.png"
|
||||||
|
},
|
||||||
"content_security_policy": {
|
"content_security_policy": {
|
||||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||||
}
|
}
|
||||||
|
|||||||
13
nginx.conf
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
gzip on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_types
|
||||||
|
application/json
|
||||||
|
application/javascript
|
||||||
|
application/xml
|
||||||
|
text/css
|
||||||
|
text/plain
|
||||||
|
text/xml
|
||||||
|
image/svg+xml;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
project_name: Vision Start
|
project_name: Vision Start
|
||||||
date: 2026-07-02
|
date: 2026-07-08
|
||||||
type: general_overview
|
type: general_overview
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ Live instances / artifacts:
|
|||||||
| Bundler / dev server | Vite 6 |
|
| Bundler / dev server | Vite 6 |
|
||||||
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
||||||
| Drag & drop | `@hello-pangea/dnd` 18 |
|
| Drag & drop | `@hello-pangea/dnd` 18 |
|
||||||
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle) |
|
| 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/` |
|
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
||||||
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
||||||
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
||||||
@@ -49,18 +49,27 @@ Entry points: `index.html` → `index.tsx` → `App.tsx`.
|
|||||||
|
|
||||||
The startpage is composed of widgets and a configuration panel:
|
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 across categories while in edit mode.
|
- **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.
|
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
|
||||||
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||||
- **Title** — Optional big header title (text + size configurable).
|
- **Title** — Optional big header title (text + size configurable).
|
||||||
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||||
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity. Supports rotating through multiple wallpapers at a cadence (`1h`–`2d`).
|
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity, rendered behind a soft readability layer for the liquid-glass UI. Supports rotating through multiple wallpapers at an hourly cadence selected by slider (`1h`–`48h`).
|
||||||
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||||
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
||||||
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
|
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
|
||||||
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
||||||
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile move/edit buttons, per-category edit buttons, and "add" buttons.
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile glass action toolbars, per-category edit buttons, and ghost glass "add" tiles.
|
||||||
- **Glassmorphism design language** — Translucent surfaces, `backdrop-blur`, subtle borders, and custom iOS-like easing tokens (`ease-ios`, `ease-spring`) defined in `index.css`.
|
- **Liquid glass design language** — Soft translucent surfaces, restrained edge highlights, moderate backdrop blur, soft shadows, cyan focus states, and iOS-like easing tokens (`ease-ios`, `ease-spring`, `ease-liquid`) defined in `index.css`.
|
||||||
|
|
||||||
|
Performance notes:
|
||||||
|
- 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`):
|
Planned / To-do (tracked in `README.md`):
|
||||||
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
@@ -74,11 +83,11 @@ vision-start/
|
|||||||
├── App.tsx # Root React component; central state + handlers
|
├── App.tsx # Root React component; central state + handlers
|
||||||
├── index.tsx # React root mount (ReactDOM.createRoot)
|
├── index.tsx # React root mount (ReactDOM.createRoot)
|
||||||
├── index.html # HTML shell, links index.css, mounts #root
|
├── index.html # HTML shell, links index.css, mounts #root
|
||||||
├── index.css # Tailwind v4 import + custom easing tokens
|
├── index.css # Tailwind v4 import + liquid glass utilities, wallpaper overlays, and easing tokens
|
||||||
|
├── vite-env.d.ts # Type declaration for CSS imports used by TypeScript verification
|
||||||
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
||||||
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
||||||
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
||||||
├── icon.png # Extension icon source
|
|
||||||
│
|
│
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── Clock.tsx # Header clock widget
|
│ ├── Clock.tsx # Header clock widget
|
||||||
@@ -86,7 +95,6 @@ vision-start/
|
|||||||
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
|
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
|
||||||
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||||
│ ├── CategoryEditModal.tsx # Add/edit a category
|
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||||
│ ├── EditModal.tsx # Legacy drag-and-drop editor (imports IconPicker & lucide-react; not wired into App.tsx)
|
|
||||||
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||||
│ ├── ServerWidget.tsx # Bottom server status pill
|
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||||
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||||
@@ -100,7 +108,7 @@ vision-start/
|
|||||||
│ │
|
│ │
|
||||||
│ ├── configuration/
|
│ ├── configuration/
|
||||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||||
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity
|
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
||||||
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||||
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||||
│ │
|
│ │
|
||||||
@@ -126,7 +134,8 @@ vision-start/
|
|||||||
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
||||||
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
||||||
│
|
│
|
||||||
├── Dockerfile # Node 22 build → nginx serving dist/
|
├── 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
|
├── .dockerignore
|
||||||
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
||||||
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
||||||
@@ -148,9 +157,9 @@ The shape of all persisted data lives in `types.ts`:
|
|||||||
- **`Server`** — `id`, `name`, `address`
|
- **`Server`** — `id`, `name`, `address`
|
||||||
- **`Category`** — `id`, `name`, `websites: Website[]`
|
- **`Category`** — `id`, `name`, `websites: Website[]`
|
||||||
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
||||||
- **`Config`** — Everything else: title, wallpaper list + frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
- **`Config`** — Everything else: title, wallpaper list + hourly frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
||||||
|
|
||||||
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers.
|
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers. Config loaded from `localStorage` (and imported from export files) is run through `normalizeConfig()`, a schema-driven deep merge against `DEFAULT_CONFIG`: nested blocks (`clock`, `serverWidget`) inherit new sub-fields added to defaults, stored values with the wrong type fall back to defaults, and keys absent from the default schema are pruned. The merge is self-healing — `App.tsx`'s persist-`useEffect` writes the normalized shape back to `localStorage` on first run.
|
||||||
|
|
||||||
Storage layout (browser-side):
|
Storage layout (browser-side):
|
||||||
|
|
||||||
@@ -211,11 +220,10 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
## 8. Notable Behaviors & Quirks
|
## 8. Notable Behaviors & Quirks
|
||||||
|
|
||||||
- **`EditModal.tsx` is not wired up.** It imports `./IconPicker` and `lucide-react`, neither of which exist in `package.json` or this tree. It appears to be a legacy/abandoned editor superseded by `WebsiteEditModal.tsx` + `CategoryEditModal.tsx`. Treat it as dead code unless reintended.
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
||||||
- **`EditModal` references `lucide-react` and `IconPicker`** which are **not** in `package.json`; do not rely on them.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
- **`@hello-pangea/dnd`** is used only inside `EditModal.tsx` (legacy) and `ServerWidgetTab.tsx` (server reorder). `WebsiteTile` moves tile via simple left/right buttons, not drag-and-drop.
|
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
||||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||||
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||||
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||||
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||||
@@ -244,4 +252,4 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-07-02. Generated as a general project overview; not a coding-style guide._
|
_Last updated: 2026-07-08. Generated as a general project overview; not a coding-style guide._
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 763 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 744 KiB |
BIN
screenshots/configuration.png
Normal file
|
After Width: | Height: | Size: 609 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.5 MiB |
BIN
screenshots/editing.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
screenshots/home.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
1
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '*.css';
|
||||||