Compare commits
8 Commits
v0.1.7
...
f35ea01c71
| Author | SHA1 | Date | |
|---|---|---|---|
| f35ea01c71 | |||
| 1580187689 | |||
| 83dcf65069 | |||
| ddf2f2a416 | |||
| c397e77c01 | |||
| 85b239f540 | |||
| 7efdd17534 | |||
| b1957f2c19 |
@@ -25,6 +25,18 @@ jobs:
|
|||||||
- name: Run build
|
- name: Run build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package dist as zip
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
zip -r ../vision-start-build.zip .
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: vision-start-build
|
||||||
|
path: vision-start-build.zip
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
runs-on: ubuntu-amd64
|
runs-on: ubuntu-amd64
|
||||||
|
|||||||
113
AGENTS.md
Normal file
113
AGENTS.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for AI agents (and humans) working on Vision Start. Read before making changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. First rule: keep `project-context.md` alive
|
||||||
|
|
||||||
|
- **Always read `project-context.md` first.** It is the canonical map of the project: structure, features, data model, flow, build/release. Treat it as required reading before any non-trivial change.
|
||||||
|
- **Always keep it updated.** Whenever you add, remove, rename, or materially change files, modules, features, storage keys, config fields, build/release steps, or workflows, update the relevant section of `project-context.md` in the same change set. If your work touches the structure tree, the data model, the storage layout, or the quick-lookup table, those sections must reflect the new state.
|
||||||
|
- Keep it a general overview, not a coding-style guide — that distinction lives here in `AGENTS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Styling guidance
|
||||||
|
|
||||||
|
The design language is **glassmorphism**: translucent black surfaces, backdrop blur, thin light borders, subtle shadows, cyan accents, and iOS-like easing.
|
||||||
|
|
||||||
|
### Surface recipe (use consistently)
|
||||||
|
- **Cards / modals / panels:** `bg-black/25 backdrop-blur-md border border-white/10 rounded-2xl shadow-lg`
|
||||||
|
- **Inputs:** `bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400`
|
||||||
|
- **Floating buttons (corner):** `bg-black/25 backdrop-blur-md border border-white/10 rounded-xl ... hover:bg-white/25 active:scale-90`
|
||||||
|
- **Full-screen modal overlay:** `bg-black/90 backdrop-blur-sm` (modals) or `bg-black/60 backdrop-blur-sm` (drawers)
|
||||||
|
- **Slide-in drawer:** `bg-black/50 backdrop-blur-xl border-l border-white/10`
|
||||||
|
|
||||||
|
### Color tokens
|
||||||
|
- Accent / focus / selection: `cyan-400` (focus ring), `cyan-500` (active/selected background)
|
||||||
|
- Confirm/Save: `green-500` → `hover:bg-green-400`
|
||||||
|
- Cancel/secondary: `gray-600` → `hover:bg-gray-500`
|
||||||
|
- Destructive: `red-500` → `hover:bg-red-400`
|
||||||
|
- Tertiary (export/import etc.): `slate-700` → `hover:bg-slate-600`
|
||||||
|
- Status: online `bg-green-500`, offline `bg-red-500`, pending `bg-gray-500`
|
||||||
|
- Text: primary `text-white`, secondary `text-slate-300`/`text-slate-400`, muted `text-white/50` (in-app hover states)
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
- Use the custom easing tokens defined in `index.css`: `ease-ios` (default) and `ease-spring` (toggle knobs, drawer slides).
|
||||||
|
- Standard durations: `duration-150` (buttons), `duration-200` (tiles, toggles, icons), `duration-250`/`duration-300` (modals, drawers).
|
||||||
|
- Micro-interactions are encouraged: `hover:scale-[1.04]`, `active:scale-90` / `active:scale-95` / `active:scale-[0.96]`, `hover:bg-white/25`.
|
||||||
|
|
||||||
|
### Tailwind specifics
|
||||||
|
- Tailwind **v4** via `@tailwindcss/vite` and `@tailwindcss/postcss`. Custom easing is in `index.css` under `@theme {}` — add new theme tokens there, not in `tailwind.config.js`.
|
||||||
|
- `tailwind.config.js` has a **safelist** of `w-[Npx]/h-[Npx]` classes because tile/icon sizes are constructed dynamically. If you add new pixel-size classes that are generated at runtime (not literally present in source), add them to the safelist or they will be purged.
|
||||||
|
- Prefer static classes; only build dynamic class strings when unavoidable and then safelist them.
|
||||||
|
|
||||||
|
### Components with established conventions to reuse
|
||||||
|
- `Dropdown` (`components/Dropdown.tsx`) for selects — single or multi. Has a glassy look and custom arrow. **Always use it** instead of `<select>`/`<input type=...>` for option picking.
|
||||||
|
- `ToggleSwitch` (`components/ToggleSwitch.tsx`) for boolean toggles — **always use it** instead of checkboxes.
|
||||||
|
- Modal pattern: 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()`).
|
||||||
|
- New editors/settings go in `components/configuration/` as a `*Tab.tsx` and are wired into `ConfigurationModal.tsx`.
|
||||||
|
- New options' size presets follow the existing scale: `tiny | small | medium | large` (see `Header.tsx`, `WebsiteTile.tsx`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. New feature guidance
|
||||||
|
|
||||||
|
A "feature" in this project typically means: a new widget, a new settings option, or a new bit of persisted state. When adding one, follow this checklist.
|
||||||
|
|
||||||
|
### A. Types & defaults (in order)
|
||||||
|
1. **`types.ts`** — add new fields to the relevant interface (usually `Config`, sometimes a nested one like `clock`/`serverWidget`). Create a nested object when a feature has 2+ sub-options (mirror the `clock`/`serverWidget` pattern).
|
||||||
|
2. **`components/services/ConfigurationService.ts` `DEFAULT_CONFIG`** — add the new field with a sane default so existing users (with a previously-saved `Config`) get it via the `{ ...DEFAULT_CONFIG, ...parsed }` merge on load.
|
||||||
|
3. Any new standalone collections (not inside `Config`) get their own storage key/helper in `ConfigurationService` (see `userWallpapers`, `wallpaperState` for the pattern).
|
||||||
|
|
||||||
|
### B. Settings UI (if the feature is user-configurable)
|
||||||
|
1. Create `components/configuration/<Feature>Tab.tsx` and add its entry to the `tabs` array in `components/ConfigurationModal.tsx`.
|
||||||
|
2. Receive `config` and an `onChange(updates: Partial<Config>)` callback; emit partial updates — `App.tsx`'s `handleConfigChange` merges shallowly, so update nested objects as whole units (e.g. `{ clock: { ...config.clock, ...updates } }`).
|
||||||
|
3. Use `Dropdown` / `ToggleSwitch` / range inputs per the styling section above.
|
||||||
|
|
||||||
|
### C. Rendering & wiring
|
||||||
|
4. Render the feature in `App.tsx` (or a dedicated component imported by `App.tsx`), gated by its `config.<feature>.enabled` flag when it can be turned off.
|
||||||
|
5. Mark up new modal/edit surfaces to match existing modals (backdrop click closes, glass card styling).
|
||||||
|
|
||||||
|
### D. Persistence — configs must be properly saved
|
||||||
|
6. `App.tsx` already persists `config` via `ConfigurationService.saveConfig(config)` in a `useEffect`. **Any state added to `Config` is persisted automatically** — do not introduce parallel ad-hoc `localStorage` writes for config fields.
|
||||||
|
7. Standalone collections (not in `Config`) need explicit save calls — mirror `userWallpapers`: load on mount, save on every mutation, never keep stale copies.
|
||||||
|
8. Never write to `chrome.storage.local` directly outside `components/utils/StorageLocalManager.ts`. Always check `checkChromeStorageLocalAvailable()` first and gracefully degrade (the app must still run as a plain web page). Throw informative errors when the storage is required but missing.
|
||||||
|
|
||||||
|
### E. Export / import must keep working
|
||||||
|
9. If you add a new `localStorage` key (other than config fields, which are exported/imported automatically), add it to `REQUIRED_LOCAL_STORAGE_KEYS` in `ConfigurationService.ts` so it is included in exports and restored on imports.
|
||||||
|
10. After adding fields to `Config`, double check the import path: `importConfig` writes each restored key to `localStorage` and returns `{ config, userWallpapers }`. New config sub-objects flow through automatically because they're inside `config`.
|
||||||
|
|
||||||
|
### F. Defaults & migrations
|
||||||
|
11. Default values must be chosen so a fresh install and an upgrade from an older `Config` both behave sensibly (the load path merges onto `DEFAULT_CONFIG`).
|
||||||
|
12. If a new field changes behavior in a way that existing users' stored data should be preserved differently, handle the migration inside `loadConfig` (and consider bumping the export `version` field in `exportConfig` if the shape changes non-additively).
|
||||||
|
|
||||||
|
### G. Style of new tiles/widgets
|
||||||
|
13. A new widget should follow the existing widget shape: optional/gated by `config.<feature>.enabled`, positioned with a glass container, and use the surface/duration/easing tokens above.
|
||||||
|
14. New "editable" tiles/content reuse the `isEditing` mode and the edit/move/delete affordances pattern from `WebsiteTile.tsx` and `CategoryGroup.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. General engineering rules
|
||||||
|
|
||||||
|
- **Language:** TypeScript strict mode (`tsconfig.json` enforces `strict`, `noUnusedLocals`, `noUnusedParameters`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`). Do not loosen these.
|
||||||
|
- **Runtime note:** Preact is the actual runtime (`@preact/preset-vite`), but types and imports use `react`/`@types/react`. Do not introduce React-only APIs without verifying Preact compatibility.
|
||||||
|
- **Path alias:** `@/*` maps to the project root (see `tsconfig.json` `paths`). Source files currently use relative imports (`./`, `../`) — match the surrounding file.
|
||||||
|
- **No comments** in committed code unless explicitly requested.
|
||||||
|
- **Build vs. dev:** Prefer verifying with `npm run build` rather than `npm run dev`. The only npm scripts are `dev`, `build`, `preview`.
|
||||||
|
- **No lint/typecheck script is configured.** Verify TS correctness manually (e.g. `npx tsc --noEmit`), and confirm `npm run build` succeeds before considering a task complete.
|
||||||
|
- **Don't touch `public/icon-metadata.json`** — it's gitignored and fetched at release-build time by `scripts/prepare_release.sh`. Don't commit a local copy.
|
||||||
|
- **Don't reintroduce dead code:** `components/EditModal.tsx` imports `lucide-react` and `./IconPicker`, neither of which is a dependency. It is not wired into the app. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead, and add the icon picker inline (as in `WebsiteEditModal.tsx`) rather than reviving `IconPicker`.
|
||||||
|
- **External asset URLs:** any new icons/css/JS pulled from CDNs (e.g. `cdn.jsdelivr.net`) must work offline-or-not without crashing — always provide a fallback (see how `iconService.ts` falls back to Google's S2 favicon service).
|
||||||
|
- **Manifest & extension constraints:** the extension uses Manifest V3 with `script-src 'self'` CSP and only the `storage` permission. Don't introduce inline scripts/eval, and avoid requesting new permissions unless essential — added permissions can disable updates on installed extensions.
|
||||||
|
- **Commits:** never commit unless explicitly asked. Don't touch secrets, don't edit `.gitignore`/`.env.local`/`.claude/`, don't run `git config`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. When in doubt
|
||||||
|
|
||||||
|
- Read `project-context.md`.
|
||||||
|
- Mirror the nearest existing equivalent (a similar widget, modal, or config tab) before inventing a new pattern.
|
||||||
|
- Favor the established glass tokens (`bg-black/25`, `backdrop-blur-md`, `border-white/10`, `cyan-400/500`) over ad-hoc colors.
|
||||||
|
- 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.
|
||||||
175
App.tsx
175
App.tsx
@@ -1,37 +1,42 @@
|
|||||||
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';
|
||||||
import CategoryGroup from './components/layout/CategoryGroup';
|
import CategoryGroup from './components/layout/CategoryGroup';
|
||||||
import Wallpaper from './components/Wallpaper';
|
import Wallpaper from './components/Wallpaper';
|
||||||
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||||
title: 'Vision Start',
|
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||||
currentWallpapers: ['Abstract'],
|
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||||
wallpaperFrequency: '1d',
|
|
||||||
wallpaperBlur: 0,
|
const getAlignmentClass = (alignment: string) => {
|
||||||
wallpaperBrightness: 100,
|
switch (alignment) {
|
||||||
wallpaperOpacity: 100,
|
case 'top':
|
||||||
titleSize: 'medium',
|
return 'justify-start';
|
||||||
alignment: 'middle',
|
case 'middle':
|
||||||
horizontalAlignment: 'middle',
|
return 'justify-center';
|
||||||
clock: {
|
case 'bottom':
|
||||||
enabled: true,
|
return 'justify-end';
|
||||||
size: 'medium',
|
default:
|
||||||
font: 'Helvetica',
|
return 'justify-center';
|
||||||
format: 'h:mm A',
|
}
|
||||||
},
|
};
|
||||||
serverWidget: {
|
|
||||||
enabled: false,
|
const getHorizontalAlignmentClass = (alignment: string) => {
|
||||||
pingFrequency: 15,
|
switch (alignment) {
|
||||||
servers: [],
|
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 = () => {
|
||||||
@@ -52,21 +57,11 @@ const App: React.FC = () => {
|
|||||||
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
const [addingWebsite, setAddingWebsite] = useState<Category | null>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||||
const [config, setConfig] = useState<Config>(() => {
|
const [config, setConfig] = useState<Config>(() => ConfigurationService.loadConfig());
|
||||||
try {
|
const [wallpaperVersion, setWallpaperVersion] = useState(0);
|
||||||
const storedConfig = localStorage.getItem('config');
|
|
||||||
if (storedConfig) {
|
|
||||||
const parsedConfig = JSON.parse(storedConfig);
|
|
||||||
return { ...defaultConfig, ...parsedConfig };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing config from localStorage', error);
|
|
||||||
}
|
|
||||||
return { ...defaultConfig };
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('config', JSON.stringify(config));
|
ConfigurationService.saveConfig(config);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,16 +72,37 @@ 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 handleSaveWebsite = (website: Partial<Website>) => {
|
const handleNextWallpaper = useCallback(() => {
|
||||||
|
const names = config.currentWallpapers;
|
||||||
|
if (names.length === 0) return;
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||||
|
const current = typeof state.currentIndex === 'number' ? state.currentIndex : 0;
|
||||||
|
const safeCurrent = current < 0 || current >= names.length ? 0 : current;
|
||||||
|
const nextIndex = (safeCurrent + 1) % names.length;
|
||||||
|
localStorage.setItem(
|
||||||
|
'wallpaperState',
|
||||||
|
JSON.stringify({
|
||||||
|
lastWallpaperChange: new Date().toISOString(),
|
||||||
|
currentIndex: nextIndex,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error advancing wallpaper state', error);
|
||||||
|
}
|
||||||
|
setWallpaperVersion(v => v + 1);
|
||||||
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
|
const handleSaveWebsite = useCallback((website: Partial<Website>) => {
|
||||||
if (editingWebsite) {
|
if (editingWebsite) {
|
||||||
const idToUpdate = website.id ?? editingWebsite.id;
|
const idToUpdate = website.id ?? editingWebsite.id;
|
||||||
const newCategories = categories.map(category => ({
|
const newCategories = categories.map(category => ({
|
||||||
@@ -113,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
|
||||||
@@ -131,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 => ({
|
||||||
@@ -142,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;
|
||||||
|
|
||||||
@@ -161,52 +177,18 @@ 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
|
||||||
@@ -218,6 +200,7 @@ const App: React.FC = () => {
|
|||||||
brightness={config.wallpaperBrightness}
|
brightness={config.wallpaperBrightness}
|
||||||
opacity={config.wallpaperOpacity}
|
opacity={config.wallpaperOpacity}
|
||||||
wallpaperFrequency={config.wallpaperFrequency}
|
wallpaperFrequency={config.wallpaperFrequency}
|
||||||
|
wallpaperVersion={wallpaperVersion}
|
||||||
/>
|
/>
|
||||||
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
|
<EditButton isEditing={isEditing} onClick={() => setIsEditing(!isEditing)} />
|
||||||
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
|
<ConfigurationButton onClick={() => setIsConfigModalOpen(true)} />
|
||||||
@@ -236,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 && (
|
||||||
@@ -260,6 +244,7 @@ const App: React.FC = () => {
|
|||||||
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
{config.serverWidget.enabled && <ServerWidget config={config} />}
|
||||||
|
|
||||||
{(editingWebsite || addingWebsite) && (
|
{(editingWebsite || addingWebsite) && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<WebsiteEditModal
|
<WebsiteEditModal
|
||||||
website={editingWebsite || undefined}
|
website={editingWebsite || undefined}
|
||||||
edit={!!editingWebsite}
|
edit={!!editingWebsite}
|
||||||
@@ -270,9 +255,11 @@ const App: React.FC = () => {
|
|||||||
onSave={handleSaveWebsite}
|
onSave={handleSaveWebsite}
|
||||||
onDelete={handleDeleteWebsite}
|
onDelete={handleDeleteWebsite}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCategoryModalOpen && (
|
{isCategoryModalOpen && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<CategoryEditModal
|
<CategoryEditModal
|
||||||
category={editingCategory || undefined}
|
category={editingCategory || undefined}
|
||||||
edit={!!editingCategory}
|
edit={!!editingCategory}
|
||||||
@@ -283,15 +270,19 @@ const App: React.FC = () => {
|
|||||||
onSave={handleSaveCategory}
|
onSave={handleSaveCategory}
|
||||||
onDelete={handleDeleteCategory}
|
onDelete={handleDeleteCategory}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConfigModalOpen && (
|
{isConfigModalOpen && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<ConfigurationModal
|
<ConfigurationModal
|
||||||
currentConfig={config}
|
currentConfig={config}
|
||||||
onClose={() => setIsConfigModalOpen(false)}
|
onClose={() => setIsConfigModalOpen(false)}
|
||||||
onSave={handleSaveConfig}
|
onSave={handleSaveConfig}
|
||||||
onWallpaperChange={handleWallpaperChange}
|
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;"]
|
||||||
|
|||||||
39
GEMINI.md
39
GEMINI.md
@@ -1,39 +0,0 @@
|
|||||||
# Vision Startpage Project
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This project is a highly customizable and stylish startpage built with React. The goal is to create a visually appealing and functional dashboard that serves as a user's entry point to the web.
|
|
||||||
|
|
||||||
## Key Features & Design Principles
|
|
||||||
|
|
||||||
* **Technology Stack:** The project is built using React and TypeScript.
|
|
||||||
* **Aesthetics:** The user interface should have a modern, "glassy" or "frosted glass" look (neumorphism/glassmorphism). This involves using transparency, blur effects, and subtle shadows to create a sense of depth.
|
|
||||||
* **Typography:** Specific font families and types will be used to maintain a consistent and elegant design.
|
|
||||||
* **Modals:** All modals in the application should follow a specific and consistent design language, contributing to the overall user experience.
|
|
||||||
* **Production Quality Code:** All code must be written to production standards, with a strong emphasis on readability, maintainability, and performance.
|
|
||||||
* **Creative & Beautiful Code:** Code should not only be functional but also well-structured, elegant, and creative.
|
|
||||||
|
|
||||||
* **Dropdown Component:** A reusable dropdown component (`components/Dropdown.tsx`) has been created for consistent styling and functionality across the application. It features a dark, glassy look with a custom arrow icon.
|
|
||||||
|
|
||||||
**Usage Example:**
|
|
||||||
```typescript jsx
|
|
||||||
import Dropdown from './components/Dropdown';
|
|
||||||
|
|
||||||
// ... inside a React component
|
|
||||||
<Dropdown
|
|
||||||
options={[
|
|
||||||
{ value: 'option1', label: 'Option 1' },
|
|
||||||
{ value: 'option2', label: 'Option 2' },
|
|
||||||
]}
|
|
||||||
value={selectedValue}
|
|
||||||
onChange={handleSelectChange}
|
|
||||||
name="myDropdown"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
* Follow the existing code style and conventions.
|
|
||||||
* Ensure all new components and features align with the established design principles.
|
|
||||||
* Write clean, commented, and reusable code.
|
|
||||||
* DO NOT run `npm run dev`, and instead, run `npm run build`.
|
|
||||||
14
README.md
14
README.md
@@ -1,5 +1,12 @@
|
|||||||
# Vision Start
|
<div style="display: flex; justify-content: center; font-size: 2rem; font-weight: bold;">
|
||||||
#### A glassmorphism-looking like, modern and customizable startpage built with React.
|
Vision Start
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: center; font-size: 1.5rem;">
|
||||||
|
A glassmorphism-looking like, modern and customizable startpage built with React.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style="display: block; text-align: center; font-size: 1.2rem;">Try it here: <a href="http://vision-start.ivanch.me">http://vision-start.ivanch.me</a></span>
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -25,7 +32,7 @@ Vision Start is not yet available on Chrome Web Store, but it can be installed m
|
|||||||
* **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.
|
* **Glassmorphism UI:** A modern and stylish interface with a frosted glass effect.
|
||||||
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
* **Icon Library:** It uses the [Dashboard Icon library](https://dashboardicons.com/) for a better look and feel. It also supports auto-fetch for some websites.
|
||||||
* **Future**: a long to do list :(
|
* **Settings:** A settings page to configure the startpage, with export/import functionality.
|
||||||
|
|
||||||
## Backgrounds
|
## Backgrounds
|
||||||
|
|
||||||
@@ -87,4 +94,3 @@ npm run dev
|
|||||||
|
|
||||||
From a technical side:
|
From a technical side:
|
||||||
* Refactor everything :(
|
* Refactor everything :(
|
||||||
* Add small nginx demo (with docker)
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,71 +1,44 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import ToggleSwitch from './ToggleSwitch';
|
import { Config, Wallpaper } from '../types';
|
||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
|
||||||
import { Server, Wallpaper } from '../types';
|
|
||||||
|
|
||||||
import Dropdown from './Dropdown';
|
|
||||||
import { baseWallpapers } from './utils/baseWallpapers';
|
import { baseWallpapers } from './utils/baseWallpapers';
|
||||||
import { addWallpaperToChromeStorageLocal, removeWallpaperFromChromeStorageLocal, checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
import { checkChromeStorageLocalAvailable } from './utils/StorageLocalManager';
|
||||||
|
import { ConfigurationService } from './services/ConfigurationService';
|
||||||
|
import GeneralTab from './configuration/GeneralTab';
|
||||||
|
import ThemeTab from './configuration/ThemeTab';
|
||||||
|
import ClockTab from './configuration/ClockTab';
|
||||||
|
import ServerWidgetTab from './configuration/ServerWidgetTab';
|
||||||
|
|
||||||
interface ConfigurationModalProps {
|
interface ConfigurationModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (config: any) => void;
|
onSave: (config: Config) => void;
|
||||||
currentConfig: any;
|
currentConfig: Config;
|
||||||
onWallpaperChange: (newConfig: Partial<any>) => void;
|
onWallpaperChange: (newConfig: Partial<Config>) => void;
|
||||||
|
onNextWallpaper: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave, currentConfig, onWallpaperChange }) => {
|
const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||||
const [config, setConfig] = useState({
|
onClose,
|
||||||
...currentConfig,
|
onSave,
|
||||||
titleSize: currentConfig.titleSize || 'medium',
|
currentConfig,
|
||||||
alignment: currentConfig.alignment || 'middle',
|
onWallpaperChange,
|
||||||
tileSize: currentConfig.tileSize || 'medium',
|
onNextWallpaper,
|
||||||
horizontalAlignment: currentConfig.horizontalAlignment || 'middle',
|
}) => {
|
||||||
wallpaperBlur: currentConfig.wallpaperBlur || 0,
|
const [config, setConfig] = useState<Config>(currentConfig);
|
||||||
wallpaperBrightness: currentConfig.wallpaperBrightness || 100,
|
|
||||||
wallpaperOpacity: currentConfig.wallpaperOpacity || 100,
|
|
||||||
serverWidget: {
|
|
||||||
enabled: false,
|
|
||||||
pingFrequency: 15,
|
|
||||||
servers: [],
|
|
||||||
...currentConfig.serverWidget,
|
|
||||||
},
|
|
||||||
clock: {
|
|
||||||
enabled: true,
|
|
||||||
size: 'medium',
|
|
||||||
font: 'Helvetica',
|
|
||||||
format: 'h:mm A',
|
|
||||||
...currentConfig.clock,
|
|
||||||
},
|
|
||||||
currentWallpapers: Array.isArray(currentConfig.currentWallpapers)
|
|
||||||
? currentConfig.currentWallpapers.filter((name: string) => typeof name === 'string')
|
|
||||||
: [],
|
|
||||||
wallpaperFrequency: currentConfig.wallpaperFrequency || '1d',
|
|
||||||
});
|
|
||||||
const [activeTab, setActiveTab] = useState('general');
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
const [newServerName, setNewServerName] = useState('');
|
|
||||||
const [newServerAddress, setNewServerAddress] = useState('');
|
|
||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
|
||||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const isSaving = useRef(false);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isSaving = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
setChromeStorageAvailable(checkChromeStorageLocalAvailable());
|
||||||
const storedUserWallpapers = localStorage.getItem('userWallpapers');
|
setUserWallpapers(ConfigurationService.loadUserWallpapers());
|
||||||
if (storedUserWallpapers) {
|
|
||||||
setUserWallpapers(JSON.parse(storedUserWallpapers));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||||
setIsVisible(true);
|
|
||||||
}, 10);
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -77,167 +50,86 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setIsVisible(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, 250);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | { target: { name: string; value: string | string[] } }) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
if (name === 'currentWallpapers') {
|
|
||||||
const wallpaperNames = Array.isArray(value) ? value : [value];
|
|
||||||
setConfig({ ...config, currentWallpapers: wallpaperNames });
|
|
||||||
} else if (name.startsWith('serverWidget.')) {
|
|
||||||
const field = name.split('.')[1];
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
serverWidget: { ...config.serverWidget, [field]: value },
|
|
||||||
});
|
|
||||||
} else if (name.startsWith('clock.')) {
|
|
||||||
const field = name.split('.')[1];
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
clock: { ...config.clock, [field]: value },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setConfig({ ...config, [name]: value });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
onWallpaperChange({ currentWallpapers: config.currentWallpapers });
|
||||||
// Set wallpaperState in localStorage with lastWallpaperChange datetime
|
ConfigurationService.resetWallpaperState();
|
||||||
localStorage.setItem('wallpaperState', JSON.stringify({
|
|
||||||
lastWallpaperChange: new Date().toISOString(),
|
|
||||||
currentIndex: 0,
|
|
||||||
}));
|
|
||||||
}, [config.currentWallpapers]);
|
}, [config.currentWallpapers]);
|
||||||
|
|
||||||
const handleClockToggleChange = (checked: boolean) => {
|
const handleClose = () => {
|
||||||
setConfig({ ...config, clock: { ...config.clock, enabled: checked } });
|
setIsVisible(false);
|
||||||
|
setTimeout(onClose, 250);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleServerWidgetToggleChange = (checked: boolean) => {
|
const handleConfigChange = (updates: Partial<Config>) => {
|
||||||
setConfig({
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
...config,
|
|
||||||
serverWidget: { ...config.serverWidget, enabled: checked },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddServer = () => {
|
const handleAddWallpaper = async (name: string, url: string) => {
|
||||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
||||||
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
const newServer: Server = {
|
setUserWallpapers(updated);
|
||||||
id: Date.now().toString(),
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
name: newServerName,
|
setConfig((prev) => ({
|
||||||
address: newServerAddress,
|
...prev,
|
||||||
|
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
setConfig({
|
const handleAddWallpaperFile = async (file: File) => {
|
||||||
...config,
|
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
||||||
serverWidget: {
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
...config.serverWidget,
|
setUserWallpapers(updated);
|
||||||
servers: [...config.serverWidget.servers, newServer],
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
},
|
setConfig((prev) => ({
|
||||||
});
|
...prev,
|
||||||
|
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
||||||
setNewServerName('');
|
}));
|
||||||
setNewServerAddress('');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveServer = (id: string) => {
|
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
serverWidget: {
|
|
||||||
...config.serverWidget,
|
|
||||||
servers: config.serverWidget.servers.filter((server: Server) => server.id !== id),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = (result: any) => {
|
|
||||||
if (!result.destination) return;
|
|
||||||
|
|
||||||
const items = Array.from(config.serverWidget.servers);
|
|
||||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
|
||||||
items.splice(result.destination.index, 0, reorderedItem);
|
|
||||||
|
|
||||||
setConfig({
|
|
||||||
...config,
|
|
||||||
serverWidget: {
|
|
||||||
...config.serverWidget,
|
|
||||||
servers: items,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddWallpaper = async () => {
|
|
||||||
if (newWallpaperUrl.trim() === '') return;
|
|
||||||
try {
|
try {
|
||||||
const finalName = await addWallpaperToChromeStorageLocal(newWallpaperName, newWallpaperUrl);
|
await ConfigurationService.deleteWallpaper(wallpaper);
|
||||||
const newWallpaper: Wallpaper = { name: finalName };
|
const updated = userWallpapers.filter((w) => w.name !== wallpaper.name);
|
||||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
setUserWallpapers(updated);
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
const newCurrentWallpapers = config.currentWallpapers.filter((n) => n !== wallpaper.name);
|
||||||
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
|
setConfig((prev) => ({ ...prev, currentWallpapers: newCurrentWallpapers }));
|
||||||
setNewWallpaperName('');
|
onWallpaperChange({ currentWallpapers: newCurrentWallpapers });
|
||||||
setNewWallpaperUrl('');
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error adding wallpaper. Please check the URL and try again.');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
if (file.size > 4 * 1024 * 1024) {
|
|
||||||
alert('File size exceeds 4MB. Please choose a smaller file.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async () => {
|
|
||||||
const base64 = reader.result as string;
|
|
||||||
if (base64.length > 4.5 * 1024 * 1024) {
|
|
||||||
alert('The uploaded image is too large. Please choose a smaller file.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
|
||||||
const newWallpaper: Wallpaper = { name: finalName };
|
|
||||||
const updatedUserWallpapers = [...userWallpapers, newWallpaper];
|
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
|
||||||
setConfig({ ...config, currentWallpapers: [...config.currentWallpapers, newWallpaper.name] });
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error adding wallpaper. Please try again.');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUserWallpaper = async (wallpaper: Wallpaper) => {
|
|
||||||
try {
|
|
||||||
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
|
||||||
const updatedUserWallpapers = userWallpapers.filter(w => w.name !== wallpaper.name);
|
|
||||||
setUserWallpapers(updatedUserWallpapers);
|
|
||||||
localStorage.setItem('userWallpapers', JSON.stringify(updatedUserWallpapers));
|
|
||||||
const newcurrentWallpapers = config.currentWallpapers.filter((name: string) => name !== wallpaper.name);
|
|
||||||
const newConfig = { ...config, currentWallpapers: newcurrentWallpapers };
|
|
||||||
setConfig(newConfig);
|
|
||||||
onWallpaperChange({ currentWallpapers: newcurrentWallpapers });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Error deleting wallpaper. Please try again.');
|
alert('Error deleting wallpaper. Please try again.');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const { config: importedConfig, userWallpapers: importedWallpapers } =
|
||||||
|
await ConfigurationService.importConfig(file);
|
||||||
|
setConfig(importedConfig);
|
||||||
|
setUserWallpapers(importedWallpapers);
|
||||||
|
onWallpaperChange({ currentWallpapers: importedConfig.currentWallpapers || [] });
|
||||||
|
onSave(importedConfig);
|
||||||
|
alert('Configuration imported successfully. The page will reload to apply all data.');
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Could not import configuration. Please use a valid export JSON file.');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
const allWallpapers = [...baseWallpapers, ...userWallpapers];
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'theme', label: 'Theme' },
|
||||||
|
{ id: 'clock', label: 'Clock' },
|
||||||
|
{ id: 'serverWidget', label: 'Server Widget' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||||
<div
|
<div
|
||||||
@@ -245,7 +137,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
isVisible ? 'opacity-100' : 'opacity-0'
|
isVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
></div>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
@@ -257,401 +149,89 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({ onClose, onSave
|
|||||||
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
<h2 className="text-3xl font-bold mb-6">Configuration</h2>
|
||||||
|
|
||||||
<div className="flex border-b border-white/10 mb-6">
|
<div className="flex border-b border-white/10 mb-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'general' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab('general')}
|
className={`px-4 py-2 text-lg font-semibold ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'text-cyan-400 border-b-2 border-cyan-400'
|
||||||
|
: 'text-slate-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
>
|
>
|
||||||
General
|
{tab.label}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'theme' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
|
||||||
onClick={() => setActiveTab('theme')}
|
|
||||||
>
|
|
||||||
Theme
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'clock' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
|
||||||
onClick={() => setActiveTab('clock')}
|
|
||||||
>
|
|
||||||
Clock
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 text-lg font-semibold ${activeTab === 'serverWidget' ? 'text-cyan-400 border-b-2 border-cyan-400' : 'text-slate-400'}`}
|
|
||||||
onClick={() => setActiveTab('serverWidget')}
|
|
||||||
>
|
|
||||||
Server Widget
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<div className="flex flex-col gap-6">
|
<GeneralTab config={config} onChange={handleConfigChange} />
|
||||||
<div>
|
|
||||||
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="title"
|
|
||||||
value={config.title}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
|
||||||
<Dropdown
|
|
||||||
name="titleSize"
|
|
||||||
value={config.titleSize}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
|
||||||
<Dropdown
|
|
||||||
name="alignment"
|
|
||||||
value={config.alignment}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'top', label: 'Top' },
|
|
||||||
{ value: 'middle', label: 'Middle' },
|
|
||||||
{ value: 'bottom', label: 'Bottom' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
|
||||||
<Dropdown
|
|
||||||
name="tileSize"
|
|
||||||
value={config.tileSize}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
|
||||||
<Dropdown
|
|
||||||
name="horizontalAlignment"
|
|
||||||
value={config.horizontalAlignment}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'left', label: 'Left' },
|
|
||||||
{ value: 'middle', label: 'Middle' },
|
|
||||||
{ value: 'right', label: 'Right' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'theme' && (
|
{activeTab === 'theme' && (
|
||||||
<div className="flex flex-col gap-6">
|
<ThemeTab
|
||||||
<div className="flex items-center justify-between">
|
config={config}
|
||||||
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
onChange={handleConfigChange}
|
||||||
<Dropdown
|
userWallpapers={userWallpapers}
|
||||||
name="currentWallpapers"
|
allWallpapers={allWallpapers}
|
||||||
value={config.currentWallpapers}
|
chromeStorageAvailable={chromeStorageAvailable}
|
||||||
onChange={handleChange}
|
onAddWallpaper={handleAddWallpaper}
|
||||||
multiple
|
onAddWallpaperFile={handleAddWallpaperFile}
|
||||||
options={allWallpapers.map(w => ({
|
onDeleteWallpaper={handleDeleteWallpaper}
|
||||||
value: w.name,
|
onNextWallpaper={onNextWallpaper}
|
||||||
label: w.name
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
|
||||||
<Dropdown
|
|
||||||
name="wallpaperFrequency"
|
|
||||||
value={config.wallpaperFrequency}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: '1h', label: '1 hour' },
|
|
||||||
{ value: '3h', label: '3 hours' },
|
|
||||||
{ value: '6h', label: '6 hours' },
|
|
||||||
{ value: '12h', label: '12 hours' },
|
|
||||||
{ value: '1d', label: '1 day' },
|
|
||||||
{ value: '2d', label: '2 days' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
name="wallpaperBlur"
|
|
||||||
min="0"
|
|
||||||
max="50"
|
|
||||||
value={config.wallpaperBlur}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-48"
|
|
||||||
/>
|
|
||||||
<span>{config.wallpaperBlur}px</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
name="wallpaperBrightness"
|
|
||||||
min="0"
|
|
||||||
max="200"
|
|
||||||
value={config.wallpaperBrightness}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-48"
|
|
||||||
/>
|
|
||||||
<span>{config.wallpaperBrightness}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
name="wallpaperOpacity"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.wallpaperOpacity}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-48"
|
|
||||||
/>
|
|
||||||
<span>{config.wallpaperOpacity}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{chromeStorageAvailable && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{userWallpapers.map((wallpaper) => (
|
|
||||||
<div key={wallpaper.name} className="flex items-center justify-between bg-white/10 p-2 rounded-lg">
|
|
||||||
<span className="truncate">{wallpaper.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUserWallpaper(wallpaper)}
|
|
||||||
className="text-red-500 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
|
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
||||||
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Wallpaper Name (optional for URLs)"
|
|
||||||
value={newWallpaperName}
|
|
||||||
onChange={(e) => setNewWallpaperName(e.target.value)}
|
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Image URL"
|
|
||||||
value={newWallpaperUrl}
|
|
||||||
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAddWallpaper}
|
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center w-full">
|
|
||||||
<label
|
|
||||||
htmlFor="file-upload"
|
|
||||||
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
|
||||||
<svg className="w-8 h-8 mb-4 text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16">
|
|
||||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"/>
|
|
||||||
</svg>
|
|
||||||
<p className="mb-2 text-sm text-gray-400"><span className="font-semibold">Click to upload</span> or drag and drop</p>
|
|
||||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
|
||||||
</div>
|
|
||||||
<input id="file-upload" type="file" className="hidden" onChange={handleFileUpload} ref={fileInputRef} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'clock' && (
|
{activeTab === 'clock' && (
|
||||||
<div className="flex flex-col gap-6">
|
<ClockTab config={config} onChange={handleConfigChange} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={config.clock.enabled}
|
|
||||||
onChange={handleClockToggleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
|
||||||
<Dropdown
|
|
||||||
name="clock.size"
|
|
||||||
value={config.clock.size}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
|
||||||
<Dropdown
|
|
||||||
name="clock.font"
|
|
||||||
value={config.clock.font}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'Helvetica', label: 'Helvetica' },
|
|
||||||
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
|
|
||||||
{ value: 'monospace', label: 'Monospace' },
|
|
||||||
{ value: 'cursive', label: 'Cursive' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
|
||||||
<Dropdown
|
|
||||||
name="clock.format"
|
|
||||||
value={config.clock.format}
|
|
||||||
onChange={handleChange}
|
|
||||||
options={[
|
|
||||||
{ value: 'h:mm A', label: 'AM/PM' },
|
|
||||||
{ value: 'HH:mm', label: '24:00' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'serverWidget' && (
|
{activeTab === 'serverWidget' && (
|
||||||
<div className="flex flex-col gap-6">
|
<ServerWidgetTab config={config} onChange={handleConfigChange} />
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={config.serverWidget.enabled}
|
|
||||||
onChange={handleServerWidgetToggleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{config.serverWidget.enabled && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
name="serverWidget.pingFrequency"
|
|
||||||
min="5"
|
|
||||||
max="60"
|
|
||||||
value={config.serverWidget.pingFrequency}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-48"
|
|
||||||
/>
|
|
||||||
<span>{config.serverWidget.pingFrequency}s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
|
||||||
<Droppable droppableId="servers">
|
|
||||||
{(provided) => (
|
|
||||||
<div {...provided.droppableProps} ref={provided.innerRef} className="flex flex-col gap-2">
|
|
||||||
{config.serverWidget.servers.map((server: Server, index: number) => (
|
|
||||||
<Draggable key={server.id} draggableId={server.id} index={index}>
|
|
||||||
{(provided) => (
|
|
||||||
<div
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">{server.name}</p>
|
|
||||||
<p className="text-sm text-slate-400">{server.address}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveServer(server.id)}
|
|
||||||
className="text-red-500 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" className="bi bi-trash" viewBox="0 0 16 16">
|
|
||||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
|
||||||
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Droppable>
|
|
||||||
</DragDropContext>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Server Name"
|
|
||||||
value={newServerName}
|
|
||||||
onChange={(e) => setNewServerName(e.target.value)}
|
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="HTTP Address"
|
|
||||||
value={newServerAddress}
|
|
||||||
onChange={(e) => setNewServerAddress(e.target.value)}
|
|
||||||
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleAddServer}
|
|
||||||
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 border-t border-white/10">
|
<div className="p-8 border-t border-white/10">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImportConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<button onClick={() => { isSaving.current = true; 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">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
isSaving.current = true;
|
||||||
|
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"
|
||||||
|
>
|
||||||
Save & Close
|
Save & Close
|
||||||
</button>
|
</button>
|
||||||
<button 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">
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,35 +12,7 @@ interface ServerWidgetProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
const getStatusColor = (status: string) => {
|
||||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const pingServers = () => {
|
|
||||||
config.serverWidget.servers.forEach((server) => {
|
|
||||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'pending' }));
|
|
||||||
ping(server.address)
|
|
||||||
.then(() => {
|
|
||||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'online' }));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setServerStatus((prevStatus) => ({ ...prevStatus, [server.id]: 'offline' }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.serverWidget.enabled) {
|
|
||||||
pingServers();
|
|
||||||
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [config.serverWidget.enabled, config.serverWidget.servers, config.serverWidget.pingFrequency]);
|
|
||||||
|
|
||||||
if (!config.serverWidget.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'online':
|
case 'online':
|
||||||
return 'bg-green-500';
|
return 'bg-green-500';
|
||||||
@@ -49,8 +21,55 @@ const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
|||||||
default:
|
default:
|
||||||
return 'bg-gray-500';
|
return 'bg-gray-500';
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||||
|
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||||
|
const serversRef = useRef(config.serverWidget.servers);
|
||||||
|
serversRef.current = config.serverWidget.servers;
|
||||||
|
|
||||||
|
const serversSignature = config.serverWidget.servers
|
||||||
|
.map(s => `${s.id}:${s.address}`)
|
||||||
|
.join('|');
|
||||||
|
const serverCount = config.serverWidget.servers.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.serverWidget.enabled) return;
|
||||||
|
|
||||||
|
const pingServers = () => {
|
||||||
|
const pending: Record<string, string> = {};
|
||||||
|
for (const s of serversRef.current) pending[s.id] = 'pending';
|
||||||
|
setServerStatus(prev => ({ ...prev, ...pending }));
|
||||||
|
|
||||||
|
serversRef.current.forEach((server) => {
|
||||||
|
ping(server.address)
|
||||||
|
.then(() => {
|
||||||
|
setServerStatus(prev =>
|
||||||
|
prev[server.id] === 'online' ? prev : { ...prev, [server.id]: 'online' }
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setServerStatus(prev =>
|
||||||
|
prev[server.id] === 'offline' ? prev : { ...prev, [server.id]: 'offline' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pingServers();
|
||||||
|
const interval = setInterval(pingServers, config.serverWidget.pingFrequency * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [
|
||||||
|
config.serverWidget.enabled,
|
||||||
|
config.serverWidget.pingFrequency,
|
||||||
|
serversSignature,
|
||||||
|
serverCount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!config.serverWidget.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-auto max-w-full">
|
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-auto max-w-full">
|
||||||
<div className="flex items-center gap-4 bg-black/25 backdrop-blur-md border border-white/20 px-4 py-2 shadow-lg"
|
<div className="flex items-center gap-4 bg-black/25 backdrop-blur-md border border-white/20 px-4 py-2 shadow-lg"
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -10,38 +10,10 @@ interface WallpaperProps {
|
|||||||
brightness: number;
|
brightness: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
wallpaperFrequency: string;
|
wallpaperFrequency: string;
|
||||||
|
wallpaperVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
const parseFrequencyToMs = (freq: string): number => {
|
||||||
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
|
||||||
if (foundInBase) {
|
|
||||||
return foundInBase.url || foundInBase.base64;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userWallpapers: WallpaperType[] = JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
|
||||||
const foundInUser = userWallpapers.find((w: WallpaperType) => w.name === name);
|
|
||||||
if (foundInUser) {
|
|
||||||
try {
|
|
||||||
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
|
||||||
if (wallpaperData && wallpaperData.startsWith('http')) {
|
|
||||||
return wallpaperData;
|
|
||||||
}
|
|
||||||
return wallpaperData || undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting wallpaper from chrome storage', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency }) => {
|
|
||||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
|
||||||
const [currentWallpaperIndex, setCurrentWallpaperIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
// Helper to parse wallpaperFrequency string to ms
|
|
||||||
const parseFrequencyToMs = (freq: string): number => {
|
|
||||||
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
if (!freq) return 24 * 60 * 60 * 1000; // default 1 day
|
||||||
const match = freq.match(/(\d+)(h|d)/);
|
const match = freq.match(/(\d+)(h|d)/);
|
||||||
if (!match) return 24 * 60 * 60 * 1000;
|
if (!match) return 24 * 60 * 60 * 1000;
|
||||||
@@ -50,53 +22,120 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
if (unit === 'h') return value * 60 * 60 * 1000;
|
if (unit === 'h') return value * 60 * 60 * 1000;
|
||||||
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
if (unit === 'd') return value * 24 * 60 * 60 * 1000;
|
||||||
return 24 * 60 * 60 * 1000;
|
return 24 * 60 * 60 * 1000;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
|
if (!name) return undefined;
|
||||||
|
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||||
|
|
||||||
|
let resolved: string | undefined;
|
||||||
|
const foundInBase = baseWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
|
if (foundInBase) {
|
||||||
|
resolved = foundInBase.url || foundInBase.base64;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const storedUserWallpapers: WallpaperType[] =
|
||||||
|
JSON.parse(localStorage.getItem('userWallpapers') || '[]');
|
||||||
|
const foundInUser = storedUserWallpapers.find((w: WallpaperType) => w.name === name);
|
||||||
|
if (foundInUser) {
|
||||||
|
try {
|
||||||
|
const wallpaperData = await getWallpaperFromChromeStorageLocal(name);
|
||||||
|
if (wallpaperData && wallpaperData.startsWith('http')) {
|
||||||
|
resolved = wallpaperData;
|
||||||
|
} else {
|
||||||
|
resolved = wallpaperData || undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting wallpaper from chrome storage', error);
|
||||||
|
resolved = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading userWallpapers from localStorage', error);
|
||||||
|
resolved = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wallpaperUrlCache.set(name, resolved);
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
|
const resolvedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateWallpaper = async () => {
|
const updateWallpaper = async () => {
|
||||||
if (wallpaperNames.length === 0) return;
|
if (wallpaperNames.length === 0) {
|
||||||
// Read wallpaperState from localStorage
|
setImageUrl(undefined);
|
||||||
|
localStorage.setItem(
|
||||||
|
'wallpaperState',
|
||||||
|
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
const wallpaperState = JSON.parse(localStorage.getItem('wallpaperState') || '{}');
|
||||||
const lastChange = wallpaperState.lastWallpaperChange ? new Date(wallpaperState.lastWallpaperChange).getTime() : 0;
|
const lastChange = wallpaperState.lastWallpaperChange
|
||||||
|
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||||
|
: 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
||||||
let currentIndex = typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
|
||||||
|
|
||||||
// If enough time has passed, pick a new wallpaper
|
let storedIndex =
|
||||||
if (now - lastChange >= freqMs) {
|
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||||
currentIndex = (currentIndex + 1) % wallpaperNames.length;
|
if (storedIndex < 0 || storedIndex >= wallpaperNames.length) storedIndex = 0;
|
||||||
localStorage.setItem('wallpaperState', JSON.stringify({
|
|
||||||
lastWallpaperChange: new Date().toISOString(),
|
const shouldRotate = now - lastChange >= freqMs;
|
||||||
currentIndex
|
let resolvedIndex = shouldRotate
|
||||||
}));
|
? (storedIndex + 1) % wallpaperNames.length
|
||||||
} else {
|
: storedIndex;
|
||||||
// Keep currentIndex in sync with localStorage if not updating
|
|
||||||
localStorage.setItem('wallpaperState', JSON.stringify({
|
const tried = new Set<number>();
|
||||||
lastWallpaperChange: wallpaperState.lastWallpaperChange || new Date().toISOString(),
|
let resolvedUrl: string | undefined;
|
||||||
currentIndex
|
|
||||||
}));
|
for (let i = 0; i < wallpaperNames.length; i++) {
|
||||||
|
if (tried.has(resolvedIndex)) break;
|
||||||
|
tried.add(resolvedIndex);
|
||||||
|
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||||
|
if (url) {
|
||||||
|
resolvedUrl = url;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
setCurrentWallpaperIndex(currentIndex);
|
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||||
const wallpaperName = wallpaperNames[currentIndex];
|
}
|
||||||
const url = await getWallpaperUrlByName(wallpaperName);
|
|
||||||
setImageUrl(url);
|
const nextLastChange = shouldRotate
|
||||||
|
? new Date().toISOString()
|
||||||
|
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
'wallpaperState',
|
||||||
|
JSON.stringify({
|
||||||
|
lastWallpaperChange: nextLastChange,
|
||||||
|
currentIndex: resolvedIndex,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
resolvedRef.current = true;
|
||||||
|
setImageUrl(resolvedUrl);
|
||||||
};
|
};
|
||||||
updateWallpaper();
|
updateWallpaper();
|
||||||
// No timer, just run on render/dependency change
|
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||||
}, [wallpaperNames, wallpaperFrequency]);
|
|
||||||
|
|
||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 -z-10 w-full h-full"
|
className="fixed inset-0 -z-10 w-full h-full wallpaper-transition"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${imageUrl})`,
|
backgroundImage: `url(${imageUrl})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (debounceRef.current) window.clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = window.setTimeout(() => {
|
||||||
const lowerCaseQuery = iconQuery.toLowerCase();
|
const lowerCaseQuery = iconQuery.toLowerCase();
|
||||||
const filtered = iconMetadata
|
const filtered: IconMetadata[] = [];
|
||||||
.filter(icon => icon.name.toLowerCase().includes(lowerCaseQuery))
|
for (const ic of iconMetadata) {
|
||||||
.slice(0, 50);
|
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);
|
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 });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,6 +144,7 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
setIcon(e.target.value);
|
setIcon(e.target.value);
|
||||||
setIconQuery(e.target.value);
|
setIconQuery(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
onFocus={ensureIconMetadata}
|
||||||
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
className="bg-white/10 p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-400 w-full"
|
||||||
/>
|
/>
|
||||||
{filteredIcons.length > 0 && (
|
{filteredIcons.length > 0 && (
|
||||||
|
|||||||
@@ -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':
|
||||||
@@ -115,4 +113,4 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsiteTile;
|
export default memo(WebsiteTile);
|
||||||
|
|||||||
69
components/configuration/ClockTab.tsx
Normal file
69
components/configuration/ClockTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Dropdown from '../Dropdown';
|
||||||
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
import { Config } from '../../types';
|
||||||
|
|
||||||
|
interface ClockTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
||||||
|
const updateClock = (updates: Partial<Config['clock']>) => {
|
||||||
|
onChange({ clock: { ...config.clock, ...updates } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Enable Clock</label>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={config.clock.enabled}
|
||||||
|
onChange={(checked) => updateClock({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Clock Size</label>
|
||||||
|
<Dropdown
|
||||||
|
name="clock.size"
|
||||||
|
value={config.clock.size}
|
||||||
|
onChange={(e) => updateClock({ size: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'tiny', label: 'Tiny' },
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'large', label: 'Large' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Clock Font</label>
|
||||||
|
<Dropdown
|
||||||
|
name="clock.font"
|
||||||
|
value={config.clock.font}
|
||||||
|
onChange={(e) => updateClock({ font: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'Helvetica', label: 'Helvetica' },
|
||||||
|
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
|
||||||
|
{ value: 'monospace', label: 'Monospace' },
|
||||||
|
{ value: 'cursive', label: 'Cursive' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Time Format</label>
|
||||||
|
<Dropdown
|
||||||
|
name="clock.format"
|
||||||
|
value={config.clock.format}
|
||||||
|
onChange={(e) => updateClock({ format: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'h:mm A', label: 'AM/PM' },
|
||||||
|
{ value: 'HH:mm', label: '24:00' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClockTab;
|
||||||
79
components/configuration/GeneralTab.tsx
Normal file
79
components/configuration/GeneralTab.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Dropdown from '../Dropdown';
|
||||||
|
import { Config } from '../../types';
|
||||||
|
|
||||||
|
interface GeneralTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-300 text-sm font-semibold mb-2 block">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config.title}
|
||||||
|
onChange={(e) => onChange({ title: e.target.value })}
|
||||||
|
className="bg-white/10 p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Title Size</label>
|
||||||
|
<Dropdown
|
||||||
|
name="titleSize"
|
||||||
|
value={config.titleSize}
|
||||||
|
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'tiny', label: 'Tiny' },
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'large', label: 'Large' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Vertical Alignment</label>
|
||||||
|
<Dropdown
|
||||||
|
name="alignment"
|
||||||
|
value={config.alignment}
|
||||||
|
onChange={(e) => onChange({ alignment: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'top', label: 'Top' },
|
||||||
|
{ value: 'middle', label: 'Middle' },
|
||||||
|
{ value: 'bottom', label: 'Bottom' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Tile Size</label>
|
||||||
|
<Dropdown
|
||||||
|
name="tileSize"
|
||||||
|
value={config.tileSize || 'medium'}
|
||||||
|
onChange={(e) => onChange({ tileSize: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'large', label: 'Large' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Horizontal Alignment</label>
|
||||||
|
<Dropdown
|
||||||
|
name="horizontalAlignment"
|
||||||
|
value={config.horizontalAlignment}
|
||||||
|
onChange={(e) => onChange({ horizontalAlignment: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: 'left', label: 'Left' },
|
||||||
|
{ value: 'middle', label: 'Middle' },
|
||||||
|
{ value: 'right', label: 'Right' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeneralTab;
|
||||||
150
components/configuration/ServerWidgetTab.tsx
Normal file
150
components/configuration/ServerWidgetTab.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
|
import { Config, Server } from '../../types';
|
||||||
|
|
||||||
|
interface ServerWidgetTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
|
const [newServerName, setNewServerName] = useState('');
|
||||||
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
|
|
||||||
|
const updateServerWidget = (updates: Partial<Config['serverWidget']>) => {
|
||||||
|
onChange({ serverWidget: { ...config.serverWidget, ...updates } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddServer = () => {
|
||||||
|
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||||
|
const newServer: Server = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: newServerName,
|
||||||
|
address: newServerAddress,
|
||||||
|
};
|
||||||
|
updateServerWidget({ servers: [...config.serverWidget.servers, newServer] });
|
||||||
|
setNewServerName('');
|
||||||
|
setNewServerAddress('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveServer = (id: string) => {
|
||||||
|
updateServerWidget({
|
||||||
|
servers: config.serverWidget.servers.filter((s) => s.id !== id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (result: any) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
const items = Array.from(config.serverWidget.servers);
|
||||||
|
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||||
|
items.splice(result.destination.index, 0, reorderedItem);
|
||||||
|
updateServerWidget({ servers: items });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Enable Server Widget</label>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={config.serverWidget.enabled}
|
||||||
|
onChange={(checked) => updateServerWidget({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{config.serverWidget.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="5"
|
||||||
|
max="60"
|
||||||
|
value={config.serverWidget.pingFrequency}
|
||||||
|
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<span>{config.serverWidget.pingFrequency}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Droppable droppableId="servers">
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
{config.serverWidget.servers.map((server: Server, index: number) => (
|
||||||
|
<Draggable key={server.id} draggableId={server.id} index={index}>
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{server.name}</p>
|
||||||
|
<p className="text-sm text-slate-400">{server.address}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveServer(server.id)}
|
||||||
|
className="text-red-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
className="bi bi-trash"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
>
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Server Name"
|
||||||
|
value={newServerName}
|
||||||
|
onChange={(e) => setNewServerName(e.target.value)}
|
||||||
|
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="HTTP Address"
|
||||||
|
value={newServerAddress}
|
||||||
|
onChange={(e) => setNewServerAddress(e.target.value)}
|
||||||
|
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddServer}
|
||||||
|
className="bg-cyan-500 hover:bg-cyan-400 text-white font-bold py-2 px-4 rounded-lg"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerWidgetTab;
|
||||||
248
components/configuration/ThemeTab.tsx
Normal file
248
components/configuration/ThemeTab.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Dropdown from '../Dropdown';
|
||||||
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
|
||||||
|
interface ThemeTabProps {
|
||||||
|
config: Config;
|
||||||
|
onChange: (updates: Partial<Config>) => void;
|
||||||
|
userWallpapers: Wallpaper[];
|
||||||
|
allWallpapers: Wallpaper[];
|
||||||
|
chromeStorageAvailable: boolean;
|
||||||
|
onAddWallpaper: (name: string, url: string) => Promise<void>;
|
||||||
|
onAddWallpaperFile: (file: File) => Promise<void>;
|
||||||
|
onDeleteWallpaper: (wallpaper: Wallpaper) => Promise<void>;
|
||||||
|
onNextWallpaper: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
userWallpapers,
|
||||||
|
allWallpapers,
|
||||||
|
chromeStorageAvailable,
|
||||||
|
onAddWallpaper,
|
||||||
|
onAddWallpaperFile,
|
||||||
|
onDeleteWallpaper,
|
||||||
|
onNextWallpaper,
|
||||||
|
}) => {
|
||||||
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleAddWallpaper = async () => {
|
||||||
|
if (newWallpaperUrl.trim() === '') return;
|
||||||
|
try {
|
||||||
|
await onAddWallpaper(newWallpaperName, newWallpaperUrl);
|
||||||
|
setNewWallpaperName('');
|
||||||
|
setNewWallpaperUrl('');
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error adding wallpaper. Please check the URL and try again.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
await onAddWallpaperFile(file);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Background</label>
|
||||||
|
<Dropdown
|
||||||
|
name="currentWallpapers"
|
||||||
|
value={config.currentWallpapers}
|
||||||
|
onChange={(e) => onChange({ currentWallpapers: e.target.value as string[] })}
|
||||||
|
multiple
|
||||||
|
options={allWallpapers.map((w) => ({ value: w.name, label: w.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
||||||
|
<Dropdown
|
||||||
|
name="wallpaperFrequency"
|
||||||
|
value={config.wallpaperFrequency}
|
||||||
|
onChange={(e) => onChange({ wallpaperFrequency: e.target.value as string })}
|
||||||
|
options={[
|
||||||
|
{ value: '1h', label: '1 hour' },
|
||||||
|
{ value: '3h', label: '3 hours' },
|
||||||
|
{ value: '6h', label: '6 hours' },
|
||||||
|
{ value: '12h', label: '12 hours' },
|
||||||
|
{ value: '1d', label: '1 day' },
|
||||||
|
{ value: '2d', label: '2 days' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={config.wallpaperBlur}
|
||||||
|
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<span>{config.wallpaperBlur}px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="200"
|
||||||
|
value={config.wallpaperBrightness}
|
||||||
|
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<span>{config.wallpaperBrightness}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={config.wallpaperOpacity}
|
||||||
|
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<span>{config.wallpaperOpacity}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{chromeStorageAvailable && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{userWallpapers.map((wallpaper) => (
|
||||||
|
<div
|
||||||
|
key={wallpaper.name}
|
||||||
|
className="flex items-center justify-between bg-white/10 p-2 rounded-lg"
|
||||||
|
>
|
||||||
|
<span className="truncate">{wallpaper.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||||
|
className="text-red-500 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
className="bi bi-trash"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
>
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Add New Wallpaper</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Wallpaper Name (optional for URLs)"
|
||||||
|
value={newWallpaperName}
|
||||||
|
onChange={(e) => setNewWallpaperName(e.target.value)}
|
||||||
|
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Image URL"
|
||||||
|
value={newWallpaperUrl}
|
||||||
|
onChange={(e) => setNewWallpaperUrl(e.target.value)}
|
||||||
|
className="bg-white/10 p-2 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddWallpaper}
|
||||||
|
className="bg-cyan-500 hover:bg-cyan-400 active:scale-95 text-white font-bold py-2 px-4 rounded-lg transition-all duration-150 ease-ios"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-white/5 border-white/20 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 mb-4 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 20 16"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="mb-2 text-sm text-gray-400">
|
||||||
|
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">PNG, JPG, WEBP, etc.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
ref={fileInputRef}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onNextWallpaper}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
>
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM4.5 7.5a.5.5 0 0 1 .5-.5h5.379L8.646 5.354a.5.5 0 1 1 .708-.708l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 0 1-.708-.708L10.379 8H5a.5.5 0 0 1-.5-.5z" />
|
||||||
|
</svg>
|
||||||
|
Next Wallpaper
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeTab;
|
||||||
@@ -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,10 +11,8 @@ 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 CategoryGroup: React.FC<CategoryGroupProps> = ({
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
@@ -25,12 +24,13 @@ 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-4 w-full ${horizontalAlignment !== 'middle' ? 'px-8' : ''}`}>
|
||||||
<h2 className={`text-2xl font-bold text-white ${config.horizontalAlignment === 'left' ? 'text-left' : config.horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${config.horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
<h2 className={`text-2xl font-bold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -45,7 +45,7 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(config.horizontalAlignment)} gap-6`}>
|
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-6`}>
|
||||||
{category.websites.map((website) => (
|
{category.websites.map((website) => (
|
||||||
<WebsiteTile
|
<WebsiteTile
|
||||||
key={website.id}
|
key={website.id}
|
||||||
@@ -53,7 +53,7 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
onEdit={setEditingWebsite}
|
onEdit={setEditingWebsite}
|
||||||
onMove={handleMoveWebsite}
|
onMove={handleMoveWebsite}
|
||||||
tileSize={config.tileSize}
|
tileSize={tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
@@ -72,4 +72,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CategoryGroup;
|
export default memo(CategoryGroup);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ const getSubtitleSizeClass = (size: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
181
components/services/ConfigurationService.ts
Normal file
181
components/services/ConfigurationService.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
import {
|
||||||
|
addWallpaperToChromeStorageLocal,
|
||||||
|
removeWallpaperFromChromeStorageLocal,
|
||||||
|
} from '../utils/StorageLocalManager';
|
||||||
|
|
||||||
|
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||||
|
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG: Config = {
|
||||||
|
title: 'Vision Start',
|
||||||
|
currentWallpapers: ['Abstract'],
|
||||||
|
wallpaperFrequency: '1d',
|
||||||
|
wallpaperBlur: 0,
|
||||||
|
wallpaperBrightness: 100,
|
||||||
|
wallpaperOpacity: 100,
|
||||||
|
titleSize: 'medium',
|
||||||
|
alignment: 'middle',
|
||||||
|
horizontalAlignment: 'middle',
|
||||||
|
tileSize: 'medium',
|
||||||
|
clock: {
|
||||||
|
enabled: true,
|
||||||
|
size: 'medium',
|
||||||
|
font: 'Helvetica',
|
||||||
|
format: 'h:mm A',
|
||||||
|
},
|
||||||
|
serverWidget: {
|
||||||
|
enabled: false,
|
||||||
|
pingFrequency: 15,
|
||||||
|
servers: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeParse = (value: string | null): unknown => {
|
||||||
|
if (value === null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toStorageString = (value: unknown): string =>
|
||||||
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
|
export const ConfigurationService = {
|
||||||
|
loadConfig(): Config {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('config');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return { ...DEFAULT_CONFIG, ...parsed };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing config from localStorage', error);
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_CONFIG };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveConfig(config: Config): void {
|
||||||
|
localStorage.setItem('config', JSON.stringify(config));
|
||||||
|
},
|
||||||
|
|
||||||
|
loadUserWallpapers(): Wallpaper[] {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('userWallpapers');
|
||||||
|
if (stored) return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
saveUserWallpapers(wallpapers: Wallpaper[]): void {
|
||||||
|
localStorage.setItem('userWallpapers', JSON.stringify(wallpapers));
|
||||||
|
},
|
||||||
|
|
||||||
|
async addWallpaper(name: string, url: string): Promise<Wallpaper> {
|
||||||
|
const finalName = await addWallpaperToChromeStorageLocal(name, url);
|
||||||
|
return { name: finalName };
|
||||||
|
},
|
||||||
|
|
||||||
|
async addWallpaperFile(file: File): Promise<Wallpaper> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (file.size > 4 * 1024 * 1024) {
|
||||||
|
reject(new Error('File size exceeds 4MB. Please choose a smaller file.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
if (base64.length > 4.5 * 1024 * 1024) {
|
||||||
|
reject(new Error('The uploaded image is too large. Please choose a smaller file.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const finalName = await addWallpaperToChromeStorageLocal(file.name, base64);
|
||||||
|
resolve({ name: finalName });
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteWallpaper(wallpaper: Wallpaper): Promise<void> {
|
||||||
|
await removeWallpaperFromChromeStorageLocal(wallpaper.name);
|
||||||
|
},
|
||||||
|
|
||||||
|
exportConfig(): void {
|
||||||
|
const exportPayload = {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
requiredLocalStorageKeys: [...REQUIRED_LOCAL_STORAGE_KEYS],
|
||||||
|
localStorage: REQUIRED_LOCAL_STORAGE_KEYS.reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = safeParse(localStorage.getItem(key));
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<RequiredLocalStorageKey, unknown>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const blob = new Blob([JSON.stringify(exportPayload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `vision-start-config-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async importConfig(file: File): Promise<{ config: Config; userWallpapers: Wallpaper[] }> {
|
||||||
|
const fileContent = await file.text();
|
||||||
|
const parsed = JSON.parse(fileContent);
|
||||||
|
const localStorageData =
|
||||||
|
parsed?.localStorage && typeof parsed.localStorage === 'object'
|
||||||
|
? parsed.localStorage
|
||||||
|
: parsed;
|
||||||
|
|
||||||
|
if (!localStorageData || typeof localStorageData !== 'object') {
|
||||||
|
throw new Error('Invalid import file format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedAny = false;
|
||||||
|
REQUIRED_LOCAL_STORAGE_KEYS.forEach((key) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(localStorageData, key)) {
|
||||||
|
const rawValue = (localStorageData as Record<string, unknown>)[key];
|
||||||
|
localStorage.setItem(key, toStorageString(rawValue));
|
||||||
|
importedAny = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!importedAny) {
|
||||||
|
throw new Error(`No required keys found. Expected: ${REQUIRED_LOCAL_STORAGE_KEYS.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedConfig = (localStorageData as Record<string, unknown>).config as Config;
|
||||||
|
const importedUserWallpapers = (localStorageData as Record<string, unknown>)
|
||||||
|
.userWallpapers as Wallpaper[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: importedConfig || { ...DEFAULT_CONFIG },
|
||||||
|
userWallpapers: Array.isArray(importedUserWallpapers) ? importedUserWallpapers : [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
resetWallpaperState(): void {
|
||||||
|
localStorage.setItem(
|
||||||
|
'wallpaperState',
|
||||||
|
JSON.stringify({
|
||||||
|
lastWallpaperChange: new Date().toISOString(),
|
||||||
|
currentIndex: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,7 @@
|
|||||||
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wallpaper-transition {
|
||||||
|
transition: filter 0.3s ease, opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|||||||
13
nginx.conf
Normal file
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,57 +0,0 @@
|
|||||||
# Notes Widget Implementation Plan
|
|
||||||
## Overview
|
|
||||||
This document outlines the implementation plan for a Notes / Scratchpad Widget feature that:
|
|
||||||
1. Provides a text area that saves content to local storage automatically
|
|
||||||
2. Includes bold and italic formatting buttons
|
|
||||||
3. Has a toggle icon to show/hide the widget
|
|
||||||
4. Has a glassy/frosty design with strong blur
|
|
||||||
## Implementation Steps
|
|
||||||
### 1. Update Config Interface
|
|
||||||
In `types.ts`, add a new `notesWidget` configuration object:
|
|
||||||
```typescript
|
|
||||||
notesWidget: {
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### 2. Update ConfigurationModal Component
|
|
||||||
In `components/ConfigurationModal.tsx`:
|
|
||||||
- Add `notesWidget` to the config state initialization (lines 18-44)
|
|
||||||
- Add a new "Notes" tab to the tab navigation (lines 259-284)
|
|
||||||
- Add a toggle switch for notesWidget.enabled in the Notes tab (lines 552-560)
|
|
||||||
- Add the Notes tab content with the toggle switch
|
|
||||||
### 3. Create NotesWidget Component
|
|
||||||
Create a new file `components/NotesWidget.tsx`:
|
|
||||||
- Text area for notes input
|
|
||||||
- Bold and italic formatting buttons
|
|
||||||
- Toggle icon for showing/hiding the widget
|
|
||||||
- Glassy/frosty design with strong blur
|
|
||||||
- Auto-save to localStorage using `userNotes` key
|
|
||||||
### 4. Update App Component
|
|
||||||
In `App.tsx`:
|
|
||||||
- Add notesWidget to the defaultConfig (lines 14-35)
|
|
||||||
- Add the NotesWidget component to the render output when enabled in config (lines 250-251)
|
|
||||||
- Add logic to save/load notes from localStorage
|
|
||||||
## Technical Details
|
|
||||||
### Configuration Structure
|
|
||||||
The configuration will be stored in the same way as other widgets:
|
|
||||||
```typescript
|
|
||||||
config: {
|
|
||||||
notesWidget: {
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
### Local Storage Key
|
|
||||||
All notes text will be saved to localStorage with the key `userNotes`.
|
|
||||||
### UI Design
|
|
||||||
- Widget will be positioned on the left side of the screen
|
|
||||||
- Vertically centered using flexbox
|
|
||||||
- Glassy/frosty design using backdrop-blur and background transparency
|
|
||||||
- Strong blur effect (backdrop-blur-2xl or similar)
|
|
||||||
- Toggle icon will be positioned on the left edge of the widget
|
|
||||||
- When hidden, icon shows a "show" indicator
|
|
||||||
- When open, icon shows a "hide" indicator
|
|
||||||
### Formatting Buttons
|
|
||||||
- Bold button (B)
|
|
||||||
- Italic button (I)
|
|
||||||
- These will use HTML formatting (bold/italic tags) or rich text approach
|
|
||||||
255
project-context.md
Normal file
255
project-context.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
---
|
||||||
|
project_name: Vision Start
|
||||||
|
date: 2026-07-02
|
||||||
|
type: general_overview
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vision Start — Project Context
|
||||||
|
|
||||||
|
A general, non-normative overview of the **Vision Start** project: what it is, how it's structured, what features it offers, and which files do what. This document is intended as a map for humans and AI agents to orient themselves in the codebase. It deliberately avoids coding-style prescriptive rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What Is This Project?
|
||||||
|
|
||||||
|
**Vision Start** is a glassmorphism-styled, highly customizable **browser startpage** (new-tab page).
|
||||||
|
|
||||||
|
- Distributed as a **Chrome/Chromium extension** (Manifest V3) that overrides the new tab with `index.html`.
|
||||||
|
- Also runnable as a **standalone web app** and shipped as a **Docker image** served via nginx.
|
||||||
|
- Built with **React + TypeScript**, bundled with **Vite**, and styled with **Tailwind CSS v4** (using the `@preact/preset-vite` so Preact is the actual React runtime).
|
||||||
|
- Persistent state lives in `localStorage` and, when available, `chrome.storage.local`.
|
||||||
|
|
||||||
|
Live instances / artifacts:
|
||||||
|
- Public demo: `http://vision-start.ivanch.me`
|
||||||
|
- Source: `https://gitea.com/ivan/vision-start.git`
|
||||||
|
- Container registry: `git.ivanch.me/ivanch/vision-start`
|
||||||
|
- Releases: `https://git.ivanch.me/ivanch/vision-start/releases/latest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technology Stack
|
||||||
|
|
||||||
|
| Layer | Tech |
|
||||||
|
|---|---|
|
||||||
|
| Language | TypeScript (~5.7), target ES2020, strict mode |
|
||||||
|
| UI runtime | Preact 10 (via `@preact/preset-vite`); types from `@types/react` 19 |
|
||||||
|
| Bundler / dev server | Vite 6 |
|
||||||
|
| Styling | Tailwind CSS v4 (`@tailwindcss/vite` plugin + `@tailwindcss/postcss` + `autoprefixer`) |
|
||||||
|
| Drag & drop | `@hello-pangea/dnd` 18 |
|
||||||
|
| Build output | Plain static files in `dist/` (relative `base: './'`, single CSS bundle; modals code-split into separate JS chunks via `React.lazy`) |
|
||||||
|
| Container | Node 22 Alpine build stage → nginx Alpine serving `dist/` |
|
||||||
|
| Extension packaging | Manifest V3 (`manifest.json`) consuming `dist/` + `manifest.json` zipped as `vision-start-<tag>.zip` |
|
||||||
|
| CI/CD | Gitea Actions workflows (`.gitea/workflows/`) |
|
||||||
|
|
||||||
|
Entry points: `index.html` → `index.tsx` → `App.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Feature Overview
|
||||||
|
|
||||||
|
The startpage is composed of widgets and a configuration panel:
|
||||||
|
|
||||||
|
- **Website Tiles** — Bookmarks organized into categories. Each tile shows an icon + name and opens the configured URL. Tiles can be added, edited, deleted, and moved left/right within their own category (reordering) while in edit mode.
|
||||||
|
- **Categories** — Groupings of website tiles (e.g. "Search"). Add/edit/delete/name.
|
||||||
|
- **Clock** — Optional header clock with selectable size, font, and 12h/24h format.
|
||||||
|
- **Title** — Optional big header title (text + size configurable).
|
||||||
|
- **Server Status Widget** — Bottom-center glass pill that periodically "pings" configured server addresses and shows online/offline indicators. Ping uses an image-load trick (`components/utils/jsping.js`) with a 5s timeout, at a configurable frequency.
|
||||||
|
- **Wallpaper background** — Fullscreen background image with adjustable blur, brightness, and opacity. Supports rotating through multiple wallpapers at a cadence (`1h`–`2d`).
|
||||||
|
- Built-in wallpapers: Abstract, Abstract Red, Beach, Dark, Mountain, Waves (`components/utils/baseWallpapers.ts`).
|
||||||
|
- User wallpapers: upload from a file (≤4MB, ≤4.5MB base64) or add by URL; stored in `chrome.storage.local` when available, falling back to storing the URL directly on CORS failure.
|
||||||
|
- **Icon library & auto-fetch** — Website icons can be picked from the [Dashboard Icons](https://dashboardicons.com/) library (metadata pre-downloaded to `public/icon-metadata.json`) or auto-fetched from the target site's `apple-touch-icon`/`icon` link tags, with a fallback to Google's S2 favicon service.
|
||||||
|
- **Configuration panel** — Slide-in right-side modal with four tabs: General, Theme, Clock, Server Widget. Includes **Export** (downloads a JSON bundle of selected `localStorage` keys) and **Import** (restores from JSON and reloads the page).
|
||||||
|
- **Edit mode** — Toggle via the top-left pencil button; reveals per-tile move/edit buttons, per-category edit buttons, and "add" buttons.
|
||||||
|
- **Glassmorphism design language** — Translucent surfaces, `backdrop-blur`, subtle borders, and custom iOS-like easing tokens (`ease-ios`, `ease-spring`) 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` and its CSS transition lives in the static `.wallpaper-transition` class in `index.css`.
|
||||||
|
|
||||||
|
Planned / To-do (tracked in `README.md`):
|
||||||
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
vision-start/
|
||||||
|
├── App.tsx # Root React component; central state + handlers
|
||||||
|
├── index.tsx # React root mount (ReactDOM.createRoot)
|
||||||
|
├── index.html # HTML shell, links index.css, mounts #root
|
||||||
|
├── index.css # Tailwind v4 import + custom easing tokens
|
||||||
|
├── types.ts # Core domain types (Config, Category, Website, Server, Wallpaper)
|
||||||
|
├── constants.tsx # DEFAULT_CATEGORIES seed data
|
||||||
|
├── manifest.json # Chrome MV3 manifest (newtab override, storage permission)
|
||||||
|
├── icon.png # Extension icon source
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── Clock.tsx # Header clock widget
|
||||||
|
│ ├── Wallpaper.tsx # Background image renderer + rotation logic
|
||||||
|
│ ├── WebsiteTile.tsx # Individual bookmark tile + loading/edit controls
|
||||||
|
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||||
|
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||||
|
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||||
|
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||||
|
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||||
|
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
||||||
|
│ │
|
||||||
|
│ ├── layout/
|
||||||
|
│ │ ├── Header.tsx # Renders Clock + Title
|
||||||
|
│ │ ├── CategoryGroup.tsx # Renders a category's title + its tiles + edit controls
|
||||||
|
│ │ ├── EditButton.tsx # Top-left pencil toggle
|
||||||
|
│ │ └── ConfigurationButton.tsx # Top-right gear button
|
||||||
|
│ │
|
||||||
|
│ ├── configuration/
|
||||||
|
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||||
|
│ │ ├── ThemeTab.tsx # Background selection, wallpaper mgmt, blur/brightness/opacity
|
||||||
|
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||||
|
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||||
|
│ │
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
||||||
|
│ │
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
||||||
|
│ ├── iconService.ts # getWebsiteIcon: fetch HTML, parse apple-touch-icon/icon, fallback to Google favicons
|
||||||
|
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
||||||
|
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper fetch/base64/URL storage
|
||||||
|
│
|
||||||
|
├── public/
|
||||||
|
│ ├── favicon.ico
|
||||||
|
│ └── icon-metadata.json # Dashboard Icons metadata (gitignored; fetched at release build)
|
||||||
|
│
|
||||||
|
├── screenshots/ # README screenshots (dark page, editing, configuration)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── prepare_release.sh # Downloads icon-metadata.json from homarr-labs/dashboard-icons
|
||||||
|
│ └── check_virustotal.sh # Uploads the release zip to VirusTotal, waits for & reports the verdict
|
||||||
|
│
|
||||||
|
├── .gitea/workflows/
|
||||||
|
│ ├── main.yaml # On push to main: build, push staging Docker image, SSH-deploy to staging
|
||||||
|
│ └── release.yaml # On v* tag: build, zip, VirusTotal check, Gitea release, push latest image, SSH-deploy to prod
|
||||||
|
│
|
||||||
|
├── Dockerfile # Node 22 build → nginx serving dist/ (with gzip via nginx.conf)
|
||||||
|
├── nginx.conf # nginx gzip config (JSON/JS/CSS/SVG/XML), mounted into container
|
||||||
|
├── .dockerignore
|
||||||
|
├── .gitignore # Ignores node_modules, dist, .claude/, public/icon-metadata.json, etc.
|
||||||
|
├── postcss.config.cjs # @tailwindcss/postcss + autoprefixer
|
||||||
|
├── tailwind.config.js # Content globs + safelist of dynamic w-/h- sizes
|
||||||
|
├── tsconfig.json # Strict TS, bundler resolution, `@/*` path alias to project root
|
||||||
|
├── vite.config.ts # preact + tailwindcss plugins; single-bundle output; base './'
|
||||||
|
├── package.json # Scripts: dev, build, preview
|
||||||
|
├── README.md # Public-facing readme + feature list + roadmap
|
||||||
|
└── .env.local # Local env (not tracked in context here)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Model & State
|
||||||
|
|
||||||
|
The shape of all persisted data lives in `types.ts`:
|
||||||
|
|
||||||
|
- **`Website`** — `id`, `name`, `url`, `icon`, `categoryId`
|
||||||
|
- **`Server`** — `id`, `name`, `address`
|
||||||
|
- **`Category`** — `id`, `name`, `websites: Website[]`
|
||||||
|
- **`Wallpaper`** — `name`, optional `url` or `base64`
|
||||||
|
- **`Config`** — Everything else: title, wallpaper list + frequency/blur/brightness/opacity, titleSize, vertical & horizontal alignment, tileSize, `clock {enabled, size, font, format}`, `serverWidget {enabled, pingFrequency, servers}`
|
||||||
|
|
||||||
|
`ConfigurationService` (`components/services/ConfigurationService.ts`) is the source of truth for default config and persistence helpers.
|
||||||
|
|
||||||
|
Storage layout (browser-side):
|
||||||
|
|
||||||
|
| Key | Where | Contents |
|
||||||
|
|---|---|---|
|
||||||
|
| `config` | `localStorage` | The full `Config` JSON |
|
||||||
|
| `categories` | `localStorage` | `Category[]` JSON |
|
||||||
|
| `userWallpapers` | `localStorage` | `Wallpaper[]` index (names) |
|
||||||
|
| `wallpaperState` | `localStorage` | `{ lastWallpaperChange, currentIndex }` for rotation |
|
||||||
|
| `<wallpaperName>` | `chrome.storage.local` (when available) | base64 (or URL on CORS failure) image data |
|
||||||
|
|
||||||
|
Export/import bundles keys: `config`, `categories`, `userWallpapers`, `wallpaperState`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Application Flow (high level)
|
||||||
|
|
||||||
|
1. `index.html` loads `index.tsx`, which mounts `<App/>` into `#root`.
|
||||||
|
2. `App.tsx` initializes state from `localStorage` (`categories`) and `ConfigurationService.loadConfig()` (`config`), falling back to defaults.
|
||||||
|
3. `useEffect` hooks persist `config` and `categories` back to `localStorage` whenever they change.
|
||||||
|
4. The screen renders:
|
||||||
|
- `<Wallpaper>` behind everything (fetches URL/base64 from base catalog or chrome.storage.local; rotates per frequency).
|
||||||
|
- `<EditButton>` (top-left) and `<ConfigurationButton>` (top-right).
|
||||||
|
- `<Header>` (clock + title).
|
||||||
|
- One `<CategoryGroup>` per category (renders its `<WebsiteTile>`s and, in edit mode, add/edit/move controls).
|
||||||
|
- Optional `<ServerWidget>` if enabled.
|
||||||
|
- Conditionally one of: `<WebsiteEditModal>`, `<CategoryEditModal>`, `<ConfigurationModal>`.
|
||||||
|
5. Edit / configuration interactions update central `App` state; the existing `useEffect`s persist it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Build, Release & Deployment
|
||||||
|
|
||||||
|
### Local development / preview
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Vite dev server (note: PROJECT.md says prefer `npm run build` for real testing)
|
||||||
|
npm run build # Production build → dist/
|
||||||
|
npm run preview # Serve built dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chrome extension install (manual)
|
||||||
|
Build, then combine `dist/` + `manifest.json` into a folder and "Load unpacked" from `chrome://extensions`. The release workflow automates this zip.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
`Dockerfile` builds in Node 22 Alpine (`npm ci` → runs `scripts/prepare_release.sh` → `npm run build`) and serves `/app/dist` + `manifest.json` via nginx:alpine on port 80.
|
||||||
|
|
||||||
|
### CI/CD (Gitea Actions)
|
||||||
|
- **`main.yaml`** — Triggers on push to `main` (and `workflow_dispatch`). Builds, pushes a `staging` multi-arch (amd64/arm64) image to `git.ivanch.me/ivanch/vision-start:staging`, then SSH-deploys on the staging host via `docker compose up -d --force-recreate`.
|
||||||
|
- **`release.yaml`** — Triggers on `v*` tags. Builds, zips `dist/` + `manifest.json` as `vision-start-<tag>.zip`, runs `scripts/check_virustotal.sh` against it (publishes analysis URL + detection ratio on the release body), creates a Gitea release, pushes a `latest` multi-arch image, and SSH-deploys to production.
|
||||||
|
|
||||||
|
Required CI secrets (referenced by the workflows): `REGISTRY_PASSWORD`, `HOST`, `USERNAME`, `KEY`, `PORT`, `STAGING_DIR`, `PROD_DIR`, `VIRUSTOTAL_APIKEY`.
|
||||||
|
|
||||||
|
External assets fetched at build time by `scripts/prepare_release.sh`:
|
||||||
|
- `https://raw.githubusercontent.com/homarr-labs/dashboard-icons/.../metadata.json` → `public/icon-metadata.json` (used by `WebsiteEditModal` for the icon picker; gitignored).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Notable Behaviors & Quirks
|
||||||
|
|
||||||
|
- **`EditModal.tsx` has been removed.** It was a legacy drag-and-drop editor that imported non-existent `lucide-react` and `./IconPicker`; it was never wired into `App.tsx`. Use `WebsiteEditModal.tsx` / `CategoryEditModal.tsx` instead.
|
||||||
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. When unavailable (e.g., running as a plain web page), wallpaper upload/delete flows are gated off and `addWallpaperToChromeStorageLocal` throws.
|
||||||
|
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, advances the index if the frequency window has elapsed, and writes it back. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Next Wallpaper" button in the Theme tab advances `currentIndex` (with wraparound) and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||||
|
- **Icon picker** in `WebsiteEditModal` loads `/icon-metadata.json` at runtime and expands each icon's `colors` into duplicate-name entries so color variants are searchable.
|
||||||
|
- **`tsconfig.json` does not emit JS** (`noEmit: true`, bundler resolution); Vite handles all transpilation.
|
||||||
|
- **`tailwind.config.js` safelists** a set of `w-[Npx]/h-[Npx]` classes because `WebsiteTile` generates tailwind classes dynamically from `tileSize` (`w-[42px]`, etc.).
|
||||||
|
- **Project guidance note** in `PROJECT.md`: do not use `npm run dev` for real verification — use `npm run build`.
|
||||||
|
- **`.claude/` and `.env.local`** are local-only / gitignored; not part of shipped artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Where Things Live (Quick Lookup)
|
||||||
|
|
||||||
|
| You want to find… | Look in… |
|
||||||
|
|---|---|
|
||||||
|
| The default config | `components/services/ConfigurationService.ts` (`DEFAULT_CONFIG`) |
|
||||||
|
| The default seed bookmarks | `constants.tsx` (`DEFAULT_CATEGORIES`) |
|
||||||
|
| Built-in wallpaper list | `components/utils/baseWallpapers.ts` |
|
||||||
|
| Type definitions | `types.ts` |
|
||||||
|
| Main app wiring (state, handlers, layout) | `App.tsx` |
|
||||||
|
| Settings UI | `components/ConfigurationModal.tsx` + `components/configuration/*Tab.tsx` |
|
||||||
|
| Wallpaper rendering/rotation | `components/Wallpaper.tsx` |
|
||||||
|
| Server status logic | `components/ServerWidget.tsx` + `components/utils/jsping.js` |
|
||||||
|
| Icon fetch / picker / metadata | `components/utils/iconService.ts`, `components/WebsiteEditModal.tsx`, `public/icon-metadata.json` |
|
||||||
|
| chrome.storage.local access | `components/utils/StorageLocalManager.ts` |
|
||||||
|
| Export/import config | `components/services/ConfigurationService.ts` (`exportConfig`, `importConfig`) |
|
||||||
|
| Release packaging | `scripts/prepare_release.sh`, `scripts/check_virustotal.sh`, `.gitea/workflows/release.yaml` |
|
||||||
|
| Docker build | `Dockerfile` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last updated: 2026-07-02. Generated as a general project overview; not a coding-style guide._
|
||||||
Reference in New Issue
Block a user