Merge pull request 'Feat/general enhancements' (#4) from feat/general-enhancements into main
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m13s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / build (push) Successful in 11m13s
Build and Release / virus-total-check (push) Successful in 4m37s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
Build and Release / Capture Vision Start Screenshots (push) Successful in 54s
Build and Release / release (push) Successful in 10s
Build and Release to Staging / Build Vision Start (push) Successful in 8s
Build and Release to Staging / Build Vision Start Image (push) Successful in 1m13s
Build and Release to Staging / Deploy Vision Start (staging) (push) Successful in 3s
Build and Release / build (push) Successful in 11m13s
Build and Release / virus-total-check (push) Successful in 4m37s
Build and Release / Build Vision Start Image (push) Successful in 1m7s
Build and Release / Deploy Vision Start (production) (push) Successful in 3s
Build and Release / Capture Vision Start Screenshots (push) Successful in 54s
Build and Release / release (push) Successful in 10s
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -39,20 +39,30 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare release
|
- name: Prepare release
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p vision-start
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
mv extension vision-start/
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
|
- name: Set archive name
|
||||||
|
run: |
|
||||||
|
SAFE_REF="${GITEA_REF_NAME//\//-}"
|
||||||
|
ARCHIVE="vision-start-${SAFE_REF}.zip"
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: |
|
run: |
|
||||||
cd vision-start
|
cd vision-start
|
||||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
zip -r "../${ARCHIVE_NAME}" *
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
path: vision-start-${{ gitea.ref_name }}.zip
|
path: ${{ env.ARCHIVE_NAME }}
|
||||||
|
|
||||||
build_vision_start:
|
build_vision_start:
|
||||||
name: Build Vision Start Image
|
name: Build Vision Start Image
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare archive
|
- name: Prepare archive
|
||||||
run: |
|
run: |
|
||||||
mkdir vision-start
|
mkdir -p vision-start
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
mv extension vision-start/
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
zip-file: vision-start-${{ gitea.ref_name }}.zip
|
zip-file: ${{ steps.set-archive.outputs.archive-name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -50,20 +50,33 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare release
|
- name: Prepare release
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p vision-start
|
||||||
mv dist vision-start/
|
mv dist vision-start/
|
||||||
mv extension vision-start/
|
mv extension vision-start/
|
||||||
mv manifest.json vision-start/
|
mv manifest.json vision-start/
|
||||||
|
|
||||||
|
- name: Set archive name
|
||||||
|
id: set-archive
|
||||||
|
run: |
|
||||||
|
SAFE_REF="${GITEA_REF_NAME//\//-}"
|
||||||
|
ARCHIVE="vision-start-${SAFE_REF}.zip"
|
||||||
|
if [ -n "${GITEA_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITEA_ENV"; fi
|
||||||
|
if [ -n "${GITHUB_ENV:-}" ]; then echo "ARCHIVE_NAME=${ARCHIVE}" >> "$GITHUB_ENV"; fi
|
||||||
|
if [ -n "${GITEA_OUTPUT:-}" ]; then echo "archive-name=${ARCHIVE}" >> "$GITEA_OUTPUT"; fi
|
||||||
|
if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "archive-name=${ARCHIVE}" >> "$GITHUB_OUTPUT"; fi
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
|
||||||
- name: Create zip archive
|
- name: Create zip archive
|
||||||
run: |
|
run: |
|
||||||
cd vision-start
|
cd vision-start
|
||||||
zip -r ../vision-start-${{ gitea.ref_name }}.zip *
|
zip -r "../${ARCHIVE_NAME}" *
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: release-zip
|
name: release-zip
|
||||||
path: vision-start-${{ gitea.ref_name }}.zip
|
path: ${{ env.ARCHIVE_NAME }}
|
||||||
|
|
||||||
virus-total-check:
|
virus-total-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -84,7 +97,7 @@ jobs:
|
|||||||
id: vt-check
|
id: vt-check
|
||||||
env:
|
env:
|
||||||
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
virustotal_apikey: ${{ secrets.VIRUSTOTAL_APIKEY }}
|
||||||
VIRUS_TOTAL_FILE: vision-start-${{ gitea.ref_name }}.zip
|
VIRUS_TOTAL_FILE: ${{ needs.build.outputs.zip-file }}
|
||||||
run: |
|
run: |
|
||||||
# Run the VirusTotal check script and capture output in real-time
|
# Run the VirusTotal check script and capture output in real-time
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
@@ -124,7 +137,7 @@ jobs:
|
|||||||
name: ${{ gitea.ref_name }}
|
name: ${{ gitea.ref_name }}
|
||||||
tag_name: ${{ gitea.ref_name }}
|
tag_name: ${{ gitea.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
vision-start-${{ gitea.ref_name }}.zip
|
${{ needs.build.outputs.zip-file }}
|
||||||
release-screenshots/home.png
|
release-screenshots/home.png
|
||||||
release-screenshots/editing.png
|
release-screenshots/editing.png
|
||||||
release-screenshots/configuration.png
|
release-screenshots/configuration.png
|
||||||
|
|||||||
@@ -8,43 +8,14 @@ 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';
|
import { ConfigurationService } from './components/services/ConfigurationService';
|
||||||
|
import { getAlignmentClass } from './components/utils/styleUtils';
|
||||||
|
import { getRandomWallpaperIndex } from './components/utils/wallpaperUtils';
|
||||||
|
import { PlusIcon } from './components/icons';
|
||||||
|
|
||||||
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
const ConfigurationModal = lazy(() => import('./components/ConfigurationModal'));
|
||||||
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
const WebsiteEditModal = lazy(() => import('./components/WebsiteEditModal'));
|
||||||
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
const CategoryEditModal = lazy(() => import('./components/CategoryEditModal'));
|
||||||
|
|
||||||
const getAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'top':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'bottom':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHorizontalAlignmentClass = (alignment: string) => {
|
|
||||||
switch (alignment) {
|
|
||||||
case 'left':
|
|
||||||
return 'justify-start';
|
|
||||||
case 'middle':
|
|
||||||
return 'justify-center';
|
|
||||||
case 'right':
|
|
||||||
return 'justify-end';
|
|
||||||
default:
|
|
||||||
return 'justify-center';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
|
||||||
if (wallpaperCount <= 1) return 0;
|
|
||||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
|
||||||
return (currentIndex + offset) % wallpaperCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<Category[]>(() => {
|
const [categories, setCategories] = useState<Category[]>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -224,13 +195,12 @@ const App: React.FC = () => {
|
|||||||
setAddingWebsite={setAddingWebsite}
|
setAddingWebsite={setAddingWebsite}
|
||||||
setEditingWebsite={setEditingWebsite}
|
setEditingWebsite={setEditingWebsite}
|
||||||
handleMoveWebsite={handleMoveWebsite}
|
handleMoveWebsite={handleMoveWebsite}
|
||||||
getHorizontalAlignmentClass={getHorizontalAlignmentClass}
|
|
||||||
horizontalAlignment={config.horizontalAlignment}
|
horizontalAlignment={config.horizontalAlignment}
|
||||||
tileSize={config.tileSize}
|
tileSize={config.tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className={`flex justify-center transition-all duration-200 ease-ios transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}>
|
<div className="flex justify-center transition-all duration-200 ease-ios transform scale-100 opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
@@ -239,10 +209,7 @@ const App: React.FC = () => {
|
|||||||
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
className="liquid-surface liquid-control liquid-ghost-tile liquid-focus min-h-16 px-6 text-sm font-bold"
|
||||||
aria-label="Add category"
|
aria-label="Add category"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PlusIcon size={22} />
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
||||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
|
||||||
</svg>
|
|
||||||
Add category
|
Add category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Category } from '../types';
|
import { Category } from '../types';
|
||||||
|
import ModalShell from './ModalShell';
|
||||||
|
|
||||||
interface CategoryEditModalProps {
|
interface CategoryEditModalProps {
|
||||||
category?: Category;
|
category?: Category;
|
||||||
@@ -12,45 +13,23 @@ interface CategoryEditModalProps {
|
|||||||
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, onClose, onSave, onDelete }) => {
|
||||||
const [name, setName] = useState(category ? category.name : '');
|
const [name, setName] = useState(category ? category.name : '');
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
<ModalShell
|
||||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
title={edit ? 'Edit Category' : 'Add Category'}
|
||||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Category' : 'Add Category'}</h2>
|
edit={edit}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={onClose}
|
||||||
<input
|
onSave={() => onSave(name)}
|
||||||
type="text"
|
onDelete={edit ? onDelete : undefined}
|
||||||
placeholder="Category Name"
|
>
|
||||||
value={name}
|
<input
|
||||||
onChange={(e) => setName(e.target.value)}
|
type="text"
|
||||||
className="liquid-input p-3"
|
placeholder="Category Name"
|
||||||
/>
|
value={name}
|
||||||
</div>
|
onChange={(e) => setName(e.target.value)}
|
||||||
<div className="flex justify-between items-center mt-8">
|
className="liquid-input p-3"
|
||||||
<div>
|
/>
|
||||||
{edit && (
|
</ModalShell>
|
||||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button onClick={() => onSave(name)} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CategoryEditModal;
|
export default CategoryEditModal;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { getClockSizeClass } from './utils/styleUtils';
|
||||||
|
|
||||||
interface ClockProps {
|
interface ClockProps {
|
||||||
config: {
|
config: {
|
||||||
@@ -9,10 +10,9 @@ interface ClockProps {
|
|||||||
format: string;
|
format: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
getClockSizeClass: (size: string) => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
const Clock: React.FC<ClockProps> = ({ config }) => {
|
||||||
const [time, setTime] = useState(new Date());
|
const [time, setTime] = useState(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,4 +66,4 @@ const Clock: React.FC<ClockProps> = ({ config, getClockSizeClass }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Clock;
|
export default Clock;
|
||||||
@@ -28,7 +28,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
const [userWallpapers, setUserWallpapers] = useState<Wallpaper[]>([]);
|
||||||
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
const [chromeStorageAvailable, setChromeStorageAvailable] = useState(false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
const importInputRef = useRef<HTMLInputElement>(null);
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isSaving = useRef(false);
|
const isSaving = useRef(false);
|
||||||
|
|
||||||
@@ -64,8 +63,8 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
setConfig((prev) => ({ ...prev, ...updates }));
|
setConfig((prev) => ({ ...prev, ...updates }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaper = async (name: string, url: string) => {
|
const handleAddWallpaperEntry = async (promise: Promise<Wallpaper>) => {
|
||||||
const newWallpaper = await ConfigurationService.addWallpaper(name, url);
|
const newWallpaper = await promise;
|
||||||
const updated = [...userWallpapers, newWallpaper];
|
const updated = [...userWallpapers, newWallpaper];
|
||||||
setUserWallpapers(updated);
|
setUserWallpapers(updated);
|
||||||
ConfigurationService.saveUserWallpapers(updated);
|
ConfigurationService.saveUserWallpapers(updated);
|
||||||
@@ -75,16 +74,11 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddWallpaperFile = async (file: File) => {
|
const handleAddWallpaper = (name: string, url: string) =>
|
||||||
const newWallpaper = await ConfigurationService.addWallpaperFile(file);
|
handleAddWallpaperEntry(ConfigurationService.addWallpaper(name, url));
|
||||||
const updated = [...userWallpapers, newWallpaper];
|
|
||||||
setUserWallpapers(updated);
|
const handleAddWallpaperFile = (file: File) =>
|
||||||
ConfigurationService.saveUserWallpapers(updated);
|
handleAddWallpaperEntry(ConfigurationService.addWallpaperFile(file));
|
||||||
setConfig((prev) => ({
|
|
||||||
...prev,
|
|
||||||
currentWallpapers: [...prev.currentWallpapers, newWallpaper.name],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
const handleDeleteWallpaper = async (wallpaper: Wallpaper) => {
|
||||||
try {
|
try {
|
||||||
@@ -140,7 +134,6 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
|
||||||
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
className={`liquid-drawer fixed top-0 right-0 h-full w-full max-w-xl text-white flex flex-col transition-transform duration-300 ease-spring transform ${
|
||||||
isVisible ? 'translate-x-0' : 'translate-x-full'
|
isVisible ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ModalShellProps {
|
||||||
|
title: string;
|
||||||
|
edit: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
||||||
|
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
||||||
|
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{title}</h2>
|
||||||
|
<div className="flex flex-col gap-4">{children}</div>
|
||||||
|
<div className="flex justify-between items-center mt-8">
|
||||||
|
<div>
|
||||||
|
{edit && onDelete && (
|
||||||
|
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalShell;
|
||||||
@@ -12,17 +12,14 @@ interface ServerWidgetProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
switch (status) {
|
online: 'bg-green-400 text-green-400',
|
||||||
case 'online':
|
offline: 'bg-red-400 text-red-400',
|
||||||
return 'bg-green-400 text-green-400';
|
|
||||||
case 'offline':
|
|
||||||
return 'bg-red-400 text-red-400';
|
|
||||||
default:
|
|
||||||
return 'bg-slate-400 text-slate-400';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string =>
|
||||||
|
STATUS_COLORS[status] ?? 'bg-slate-400 text-slate-400';
|
||||||
|
|
||||||
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
const ServerWidget: React.FC<ServerWidgetProps> = ({ config }) => {
|
||||||
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
const [serverStatus, setServerStatus] = useState<Record<string, string>>({});
|
||||||
const serversRef = useRef(config.serverWidget.servers);
|
const serversRef = useRef(config.serverWidget.servers);
|
||||||
|
|||||||
+25
-28
@@ -1,8 +1,8 @@
|
|||||||
|
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';
|
||||||
|
import { getRandomWallpaperIndex, getWallpaperFrequencyMs } from './utils/wallpaperUtils';
|
||||||
|
|
||||||
interface WallpaperProps {
|
interface WallpaperProps {
|
||||||
wallpaperNames: string[];
|
wallpaperNames: string[];
|
||||||
@@ -13,28 +13,18 @@ interface WallpaperProps {
|
|||||||
wallpaperVersion: number;
|
wallpaperVersion: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_WALLPAPER_FREQUENCY_MS = 60 * 60 * 1000;
|
const MAX_WALLPAPER_URL_CACHE = 3;
|
||||||
const MAX_WALLPAPER_FREQUENCY_MS = 48 * 60 * 60 * 1000;
|
|
||||||
const DEFAULT_WALLPAPER_FREQUENCY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const parseFrequencyToMs = (freq: string): number => {
|
|
||||||
if (!freq) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
|
||||||
const match = freq.match(/^(\d+)(h|d)$/);
|
|
||||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_MS;
|
|
||||||
const value = parseInt(match[1], 10);
|
|
||||||
const unit = match[2];
|
|
||||||
const frequencyMs = unit === 'd' ? value * 24 * 60 * 60 * 1000 : value * 60 * 60 * 1000;
|
|
||||||
return Math.min(MAX_WALLPAPER_FREQUENCY_MS, Math.max(MIN_WALLPAPER_FREQUENCY_MS, frequencyMs));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
|
||||||
if (wallpaperCount <= 1) return 0;
|
|
||||||
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
|
||||||
return (currentIndex + offset) % wallpaperCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wallpaperUrlCache = new Map<string, string | undefined>();
|
const wallpaperUrlCache = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
const rememberWallpaperUrl = (name: string, resolved: string | undefined): void => {
|
||||||
|
wallpaperUrlCache.set(name, resolved);
|
||||||
|
while (wallpaperUrlCache.size > MAX_WALLPAPER_URL_CACHE) {
|
||||||
|
const oldest = wallpaperUrlCache.keys().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
wallpaperUrlCache.delete(oldest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
const getWallpaperUrlByName = async (name: string): Promise<string | undefined> => {
|
||||||
if (!name) return undefined;
|
if (!name) return undefined;
|
||||||
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
if (wallpaperUrlCache.has(name)) return wallpaperUrlCache.get(name);
|
||||||
@@ -65,18 +55,19 @@ const getWallpaperUrlByName = async (name: string): Promise<string | undefined>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wallpaperUrlCache.set(name, resolved);
|
rememberWallpaperUrl(name, resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness, opacity, wallpaperFrequency, wallpaperVersion }) => {
|
||||||
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
const [imageUrl, setImageUrl] = useState<string | undefined>(undefined);
|
||||||
const resolvedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const updateWallpaper = async () => {
|
const updateWallpaper = async () => {
|
||||||
if (wallpaperNames.length === 0) {
|
if (wallpaperNames.length === 0) {
|
||||||
setImageUrl(undefined);
|
if (!cancelled) setImageUrl(undefined);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'wallpaperState',
|
'wallpaperState',
|
||||||
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
JSON.stringify({ lastWallpaperChange: new Date().toISOString(), currentIndex: 0 }),
|
||||||
@@ -89,7 +80,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
? new Date(wallpaperState.lastWallpaperChange).getTime()
|
||||||
: 0;
|
: 0;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const freqMs = parseFrequencyToMs(wallpaperFrequency);
|
const freqMs = getWallpaperFrequencyMs(wallpaperFrequency);
|
||||||
|
|
||||||
let storedIndex =
|
let storedIndex =
|
||||||
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
typeof wallpaperState.currentIndex === 'number' ? wallpaperState.currentIndex : 0;
|
||||||
@@ -107,6 +98,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
if (tried.has(resolvedIndex)) break;
|
if (tried.has(resolvedIndex)) break;
|
||||||
tried.add(resolvedIndex);
|
tried.add(resolvedIndex);
|
||||||
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
const url = await getWallpaperUrlByName(wallpaperNames[resolvedIndex]);
|
||||||
|
if (cancelled) return;
|
||||||
if (url) {
|
if (url) {
|
||||||
resolvedUrl = url;
|
resolvedUrl = url;
|
||||||
break;
|
break;
|
||||||
@@ -114,6 +106,8 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
resolvedIndex = (resolvedIndex + 1) % wallpaperNames.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
const nextLastChange = shouldRotate
|
const nextLastChange = shouldRotate
|
||||||
? new Date().toISOString()
|
? new Date().toISOString()
|
||||||
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
: wallpaperState.lastWallpaperChange || new Date().toISOString();
|
||||||
@@ -126,10 +120,13 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
resolvedRef.current = true;
|
|
||||||
setImageUrl(resolvedUrl);
|
setImageUrl(resolvedUrl);
|
||||||
};
|
};
|
||||||
updateWallpaper();
|
updateWallpaper();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
}, [wallpaperNames, wallpaperFrequency, wallpaperVersion]);
|
||||||
|
|
||||||
if (!imageUrl) return null;
|
if (!imageUrl) return null;
|
||||||
@@ -150,4 +147,4 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Wallpaper;
|
export default Wallpaper;
|
||||||
+87
-104
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } 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';
|
||||||
|
import ModalShell from './ModalShell';
|
||||||
|
|
||||||
interface WebsiteEditModalProps {
|
interface WebsiteEditModalProps {
|
||||||
website?: Website;
|
website?: Website;
|
||||||
@@ -27,6 +28,9 @@ interface IconMetadata {
|
|||||||
|
|
||||||
let iconMetadataCache: IconMetadata[] | null = null;
|
let iconMetadataCache: IconMetadata[] | null = null;
|
||||||
|
|
||||||
|
const getIconPickUrl = (iconData: IconMetadata): string =>
|
||||||
|
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
||||||
|
|
||||||
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 : '');
|
||||||
@@ -37,6 +41,12 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
const [iconsFetched, setIconsFetched] = useState(() => iconMetadataCache !== null);
|
||||||
const debounceRef = useRef<number | null>(null);
|
const debounceRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
iconMetadataCache = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const ensureIconMetadata = () => {
|
const ensureIconMetadata = () => {
|
||||||
if (iconMetadataCache) {
|
if (iconMetadataCache) {
|
||||||
setIconMetadata(iconMetadataCache);
|
setIconMetadata(iconMetadataCache);
|
||||||
@@ -68,10 +78,10 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
filtered.push(ic);
|
filtered.push(ic);
|
||||||
if (filtered.length >= 50) break;
|
if (filtered.length >= 50) break;
|
||||||
}
|
}
|
||||||
if (ic.colors) {
|
if (ic.colors && typeof ic.colors === 'object') {
|
||||||
const colors = Object.values(ic.colors).filter(key => key !== ic.name);
|
const colors = Object.values(ic.colors).filter(key => typeof key === 'string' && key !== ic.name);
|
||||||
for (const color of colors) {
|
for (const color of colors as string[]) {
|
||||||
if (typeof color === 'string' && color.toLowerCase().includes(lowerCaseQuery)) {
|
if (color.toLowerCase().includes(lowerCaseQuery)) {
|
||||||
filtered.push({ ...ic, name: color });
|
filtered.push({ ...ic, name: color });
|
||||||
if (filtered.length >= 50) break;
|
if (filtered.length >= 50) break;
|
||||||
}
|
}
|
||||||
@@ -96,109 +106,82 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onSave({ id: website?.id, name, url, icon });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="liquid-modal-backdrop fixed inset-0 flex items-center justify-center z-50 p-4" onClick={handleOverlayClick}>
|
<ModalShell
|
||||||
<div className="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white">
|
title={edit ? 'Edit Website' : 'Add Website'}
|
||||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">{edit ? 'Edit Website' : 'Add Website'}</h2>
|
edit={edit}
|
||||||
<div className="flex flex-col gap-4">
|
onClose={onClose}
|
||||||
<div className="flex justify-center mb-4">
|
onSave={() => onSave({ id: website?.id, name, url, icon })}
|
||||||
{icon ? (
|
onDelete={edit ? onDelete : undefined}
|
||||||
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
>
|
||||||
) : (
|
<div className="flex justify-center mb-4">
|
||||||
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
{icon ? (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
<img src={icon} alt="Website Icon" className="h-24 w-24 object-contain" />
|
||||||
<circle cx="12" cy="12" r="10"></circle>
|
) : (
|
||||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
<div className="liquid-surface h-24 w-24 rounded-2xl flex items-center justify-center">
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="text-white/50">
|
||||||
</svg>
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
</div>
|
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||||
)}
|
<path d="M12 2a15.3 15.3 0 0 1 4 18 15.3 15.3 0 0 1-8 0 15.3 15.3 0 0 1 4-18z"></path>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
)}
|
||||||
type="text"
|
|
||||||
placeholder="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="liquid-input p-3"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="URL"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
className="liquid-input p-3"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Icon URL or name"
|
|
||||||
value={icon}
|
|
||||||
onChange={(e) => {
|
|
||||||
setIcon(e.target.value);
|
|
||||||
setIconQuery(e.target.value);
|
|
||||||
}}
|
|
||||||
onFocus={ensureIconMetadata}
|
|
||||||
className="liquid-input p-3"
|
|
||||||
/>
|
|
||||||
{filteredIcons.length > 0 && (
|
|
||||||
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
|
||||||
{filteredIcons.map(iconData => (
|
|
||||||
<div
|
|
||||||
key={iconData.name}
|
|
||||||
onClick={() => {
|
|
||||||
const iconUrl = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`;
|
|
||||||
setIcon(iconUrl);
|
|
||||||
setFilteredIcons([]);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/${iconData.base}/${iconData.name}.${iconData.base}`}
|
|
||||||
alt={iconData.name}
|
|
||||||
className="h-6 w-6 mr-2"
|
|
||||||
/>
|
|
||||||
<span>{iconData.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
|
||||||
Fetch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center mt-8">
|
|
||||||
<div>
|
|
||||||
{edit && (
|
|
||||||
<button onClick={onDelete} className="liquid-button liquid-button-danger liquid-focus py-2.5 px-5">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button onClick={handleSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="liquid-input p-3"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="URL"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
className="liquid-input p-3"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Icon URL or name"
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIcon(e.target.value);
|
||||||
|
setIconQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={ensureIconMetadata}
|
||||||
|
className="liquid-input p-3"
|
||||||
|
/>
|
||||||
|
{filteredIcons.length > 0 && (
|
||||||
|
<div className="liquid-panel liquid-dropdown-list absolute z-20 w-full rounded-xl mt-2 max-h-60 overflow-y-auto">
|
||||||
|
{filteredIcons.map((iconData, index) => (
|
||||||
|
<div
|
||||||
|
key={`${iconData.name}-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIcon(getIconPickUrl(iconData));
|
||||||
|
setFilteredIcons([]);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer flex items-center p-2 transition-colors duration-150 ease-ios hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getIconPickUrl(iconData)}
|
||||||
|
alt={iconData.name}
|
||||||
|
className="h-6 w-6 mr-2"
|
||||||
|
/>
|
||||||
|
<span>{iconData.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchIcon} className="liquid-button liquid-button-secondary liquid-focus py-3 px-4">
|
||||||
|
Fetch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsiteEditModal;
|
export default WebsiteEditModal;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo, useEffect, useState } from 'react';
|
import React, { memo, useEffect, useState } from 'react';
|
||||||
import { Website } from '../types';
|
import { Website } from '../types';
|
||||||
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
|
||||||
|
import { getTileSizeClass, getIconPixelSize, getIconLoadingPixelSize } from './utils/styleUtils';
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, PencilIcon } from './icons';
|
||||||
|
|
||||||
interface WebsiteTileProps {
|
interface WebsiteTileProps {
|
||||||
website: Website;
|
website: Website;
|
||||||
@@ -10,46 +12,6 @@ interface WebsiteTileProps {
|
|||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTileSizeClass = (size: string | undefined) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const getIconPixelSize = (size: string | undefined): number => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 34;
|
|
||||||
case 'medium':
|
|
||||||
return 42;
|
|
||||||
case 'large':
|
|
||||||
return 48;
|
|
||||||
default:
|
|
||||||
return 40;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIconLoadingPixelSize = (size: string | undefined): number => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 24;
|
|
||||||
case 'medium':
|
|
||||||
return 32;
|
|
||||||
case 'large':
|
|
||||||
return 40;
|
|
||||||
default:
|
|
||||||
return 32;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -141,15 +103,9 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
|
|||||||
</a>
|
</a>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="liquid-surface liquid-edit-toolbar">
|
<div className="liquid-surface liquid-edit-toolbar">
|
||||||
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<button onClick={() => onMove(website, 'left')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} left`}><ChevronLeftIcon size={14} /></button>
|
||||||
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><PencilIcon size={14} /></button>
|
||||||
</svg></button>
|
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><ChevronRightIcon size={14} /></button>
|
||||||
<button onClick={() => onEdit(website)} className="liquid-edit-action liquid-focus" aria-label={`Edit ${website.name}`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
|
||||||
</svg></button>
|
|
||||||
<button onClick={() => onMove(website, 'right')} className="liquid-edit-action liquid-focus" aria-label={`Move ${website.name} right`}><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
|
||||||
</svg></button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface ClockTabProps {
|
interface ClockTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -28,12 +29,7 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
|||||||
name="clock.size"
|
name="clock.size"
|
||||||
value={config.clock.size}
|
value={config.clock.size}
|
||||||
onChange={(e) => updateClock({ size: e.target.value as string })}
|
onChange={(e) => updateClock({ size: e.target.value as string })}
|
||||||
options={[
|
options={SIZE_OPTIONS}
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { SIZE_OPTIONS } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface GeneralTabProps {
|
interface GeneralTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -25,12 +26,7 @@ const GeneralTab: React.FC<GeneralTabProps> = ({ config, onChange }) => {
|
|||||||
name="titleSize"
|
name="titleSize"
|
||||||
value={config.titleSize}
|
value={config.titleSize}
|
||||||
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
onChange={(e) => onChange({ titleSize: e.target.value as string })}
|
||||||
options={[
|
options={SIZE_OPTIONS}
|
||||||
{ value: 'tiny', label: 'Tiny' },
|
|
||||||
{ value: 'small', label: 'Small' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'large', label: 'Large' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
||||||
|
|
||||||
|
export const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
||||||
|
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
||||||
|
return { '--range-progress': `${progress}%` };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RangeSliderProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
valueSuffix?: string;
|
||||||
|
formatValue?: (value: number) => string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RangeSlider: React.FC<RangeSliderProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
valueSuffix,
|
||||||
|
formatValue,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<label className="text-slate-300 text-sm font-semibold">{label}</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="liquid-range"
|
||||||
|
style={getRangeStyle(value, min, max)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-20 text-right text-sm text-slate-200">
|
||||||
|
{formatValue ? formatValue(value) : `${value}${valueSuffix ?? ''}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RangeSlider;
|
||||||
@@ -2,19 +2,14 @@ import React, { useState } from 'react';
|
|||||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||||
import ToggleSwitch from '../ToggleSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import { Config, Server } from '../../types';
|
import { Config, Server } from '../../types';
|
||||||
|
import RangeSlider from './RangeSlider';
|
||||||
|
import { TrashIcon } from '../icons';
|
||||||
|
|
||||||
interface ServerWidgetTabProps {
|
interface ServerWidgetTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
onChange: (updates: Partial<Config>) => void;
|
onChange: (updates: Partial<Config>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
|
||||||
|
|
||||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
|
||||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
||||||
return { '--range-progress': `${progress}%` };
|
|
||||||
};
|
|
||||||
|
|
||||||
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) => {
|
||||||
const [newServerName, setNewServerName] = useState('');
|
const [newServerName, setNewServerName] = useState('');
|
||||||
const [newServerAddress, setNewServerAddress] = useState('');
|
const [newServerAddress, setNewServerAddress] = useState('');
|
||||||
@@ -60,21 +55,14 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
</div>
|
</div>
|
||||||
{config.serverWidget.enabled && (
|
{config.serverWidget.enabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Ping Frequency</label>
|
label="Ping Frequency"
|
||||||
<div className="flex items-center gap-4">
|
value={config.serverWidget.pingFrequency}
|
||||||
<input
|
min={5}
|
||||||
type="range"
|
max={60}
|
||||||
min="5"
|
valueSuffix="s"
|
||||||
max="60"
|
onChange={(value) => updateServerWidget({ pingFrequency: value })}
|
||||||
value={config.serverWidget.pingFrequency}
|
/>
|
||||||
onChange={(e) => updateServerWidget({ pingFrequency: Number(e.target.value) })}
|
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(config.serverWidget.pingFrequency, 5, 60)}
|
|
||||||
/>
|
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.serverWidget.pingFrequency}s</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">Servers</h3>
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
@@ -103,20 +91,7 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
aria-label={`Remove ${server.name}`}
|
aria-label={`Remove ${server.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<TrashIcon size={16} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -156,4 +131,4 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ServerWidgetTab;
|
export default ServerWidgetTab;
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import Dropdown from '../Dropdown';
|
import Dropdown from '../Dropdown';
|
||||||
import { Config, Wallpaper } from '../../types';
|
import { Config, Wallpaper } from '../../types';
|
||||||
|
import RangeSlider from './RangeSlider';
|
||||||
|
import { TrashIcon } from '../icons';
|
||||||
|
import {
|
||||||
|
getWallpaperFrequencyHours,
|
||||||
|
formatWallpaperFrequency,
|
||||||
|
MIN_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
MAX_WALLPAPER_FREQUENCY_HOURS,
|
||||||
|
} from '../utils/wallpaperUtils';
|
||||||
|
|
||||||
interface ThemeTabProps {
|
interface ThemeTabProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -14,31 +22,6 @@ interface ThemeTabProps {
|
|||||||
onRandomWallpaper: () => void;
|
onRandomWallpaper: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeStyle = React.CSSProperties & { '--range-progress': string };
|
|
||||||
|
|
||||||
const getRangeStyle = (value: number, min: number, max: number): RangeStyle => {
|
|
||||||
const progress = Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100));
|
|
||||||
return { '--range-progress': `${progress}%` };
|
|
||||||
};
|
|
||||||
|
|
||||||
const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
|
||||||
const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
|
||||||
const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
|
||||||
|
|
||||||
const clampWallpaperFrequencyHours = (hours: number): number =>
|
|
||||||
Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, Math.round(hours)));
|
|
||||||
|
|
||||||
const getWallpaperFrequencyHours = (frequency: string): number => {
|
|
||||||
const match = frequency.match(/^(\d+)(h|d)$/);
|
|
||||||
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
|
||||||
const value = Number(match[1]);
|
|
||||||
const hours = match[2] === 'd' ? value * 24 : value;
|
|
||||||
return clampWallpaperFrequencyHours(hours);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatWallpaperFrequency = (hours: number): string =>
|
|
||||||
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
||||||
|
|
||||||
const ThemeTab: React.FC<ThemeTabProps> = ({
|
const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -52,7 +35,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [newWallpaperName, setNewWallpaperName] = useState('');
|
const [newWallpaperName, setNewWallpaperName] = useState('');
|
||||||
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
const [newWallpaperUrl, setNewWallpaperUrl] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
const wallpaperFrequencyHours = getWallpaperFrequencyHours(config.wallpaperFrequency);
|
||||||
|
|
||||||
const handleAddWallpaper = async () => {
|
const handleAddWallpaper = async () => {
|
||||||
@@ -76,8 +58,8 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await onAddWallpaperFile(file);
|
await onAddWallpaperFile(file);
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
alert(error?.message || 'Error adding wallpaper. Please try again.');
|
alert(error instanceof Error ? error.message : 'Error adding wallpaper. Please try again.');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
@@ -96,74 +78,39 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
{Array.isArray(config.currentWallpapers) && config.currentWallpapers.length > 1 && (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Change Frequency</label>
|
label="Change Frequency"
|
||||||
<div className="flex items-center gap-4">
|
value={wallpaperFrequencyHours}
|
||||||
<input
|
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
||||||
type="range"
|
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
||||||
min={MIN_WALLPAPER_FREQUENCY_HOURS}
|
formatValue={formatWallpaperFrequency}
|
||||||
max={MAX_WALLPAPER_FREQUENCY_HOURS}
|
onChange={(value) => onChange({ wallpaperFrequency: `${value}h` })}
|
||||||
step="1"
|
/>
|
||||||
value={wallpaperFrequencyHours}
|
|
||||||
onChange={(e) => onChange({ wallpaperFrequency: `${Number(e.target.value)}h` })}
|
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(
|
|
||||||
wallpaperFrequencyHours,
|
|
||||||
MIN_WALLPAPER_FREQUENCY_HOURS,
|
|
||||||
MAX_WALLPAPER_FREQUENCY_HOURS,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="w-20 text-right text-sm text-slate-200">
|
|
||||||
{formatWallpaperFrequency(wallpaperFrequencyHours)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<RangeSlider
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Blur</label>
|
label="Wallpaper Blur"
|
||||||
<div className="flex items-center gap-4">
|
value={config.wallpaperBlur}
|
||||||
<input
|
min={0}
|
||||||
type="range"
|
max={50}
|
||||||
min="0"
|
valueSuffix="px"
|
||||||
max="50"
|
onChange={(value) => onChange({ wallpaperBlur: value })}
|
||||||
value={config.wallpaperBlur}
|
/>
|
||||||
onChange={(e) => onChange({ wallpaperBlur: Number(e.target.value) })}
|
<RangeSlider
|
||||||
className="liquid-range"
|
label="Wallpaper Brightness"
|
||||||
style={getRangeStyle(config.wallpaperBlur, 0, 50)}
|
value={config.wallpaperBrightness}
|
||||||
/>
|
min={0}
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBlur}px</span>
|
max={200}
|
||||||
</div>
|
valueSuffix="%"
|
||||||
</div>
|
onChange={(value) => onChange({ wallpaperBrightness: value })}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
/>
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Brightness</label>
|
<RangeSlider
|
||||||
<div className="flex items-center gap-4">
|
label="Wallpaper Opacity"
|
||||||
<input
|
value={config.wallpaperOpacity}
|
||||||
type="range"
|
min={1}
|
||||||
min="0"
|
max={100}
|
||||||
max="200"
|
valueSuffix="%"
|
||||||
value={config.wallpaperBrightness}
|
onChange={(value) => onChange({ wallpaperOpacity: value })}
|
||||||
onChange={(e) => onChange({ wallpaperBrightness: Number(e.target.value) })}
|
/>
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(config.wallpaperBrightness, 0, 200)}
|
|
||||||
/>
|
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperBrightness}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<label className="text-slate-300 text-sm font-semibold">Wallpaper Opacity</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value={config.wallpaperOpacity}
|
|
||||||
onChange={(e) => onChange({ wallpaperOpacity: Number(e.target.value) })}
|
|
||||||
className="liquid-range"
|
|
||||||
style={getRangeStyle(config.wallpaperOpacity, 1, 100)}
|
|
||||||
/>
|
|
||||||
<span className="w-12 text-right text-sm text-slate-200">{config.wallpaperOpacity}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
<h3 className="text-slate-300 text-sm font-semibold mb-2">User Wallpapers</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -178,20 +125,7 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||||
aria-label={`Delete ${wallpaper.name}`}
|
aria-label={`Delete ${wallpaper.name}`}
|
||||||
>
|
>
|
||||||
<svg
|
<TrashIcon size={16} />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -254,7 +188,6 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
ref={fileInputRef}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,4 +216,4 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ThemeTab;
|
export default ThemeTab;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PencilIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlusIcon: React.FC<IconProps> = ({ size = 22, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon: React.FC<IconProps> = ({ size = 16, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
|
<path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronLeftIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path fillRule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChevronRightIcon: React.FC<IconProps> = ({ size = 14, className }) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} fill="currentColor" viewBox="0 0 16 16" aria-hidden="true" className={className}>
|
||||||
|
<path fillRule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import WebsiteTile from '../WebsiteTile';
|
import WebsiteTile from '../WebsiteTile';
|
||||||
import { Category, Website } from '../../types';
|
import { Category, Website } from '../../types';
|
||||||
|
import { getTileSizeClass, getAlignmentClass } from '../utils/styleUtils';
|
||||||
|
import { PencilIcon, PlusIcon } from '../icons';
|
||||||
|
|
||||||
interface CategoryGroupProps {
|
interface CategoryGroupProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
@@ -10,24 +12,10 @@ interface CategoryGroupProps {
|
|||||||
setAddingWebsite: (category: Category) => void;
|
setAddingWebsite: (category: Category) => void;
|
||||||
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;
|
|
||||||
horizontalAlignment: string;
|
horizontalAlignment: string;
|
||||||
tileSize?: string;
|
tileSize?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAddTileSizeClass = (size: string | undefined) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'small':
|
|
||||||
return 'w-28 h-28';
|
|
||||||
case 'medium':
|
|
||||||
return 'w-32 h-32';
|
|
||||||
case 'large':
|
|
||||||
return 'w-36 h-36';
|
|
||||||
default:
|
|
||||||
return 'w-32 h-32';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
||||||
category,
|
category,
|
||||||
isEditing,
|
isEditing,
|
||||||
@@ -36,13 +24,12 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
setAddingWebsite,
|
setAddingWebsite,
|
||||||
setEditingWebsite,
|
setEditingWebsite,
|
||||||
handleMoveWebsite,
|
handleMoveWebsite,
|
||||||
getHorizontalAlignmentClass,
|
|
||||||
horizontalAlignment,
|
horizontalAlignment,
|
||||||
tileSize,
|
tileSize,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div key={category.id} className="w-full">
|
<div key={category.id} className="w-full">
|
||||||
<div className={`flex ${getHorizontalAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
<div className={`flex ${getAlignmentClass(horizontalAlignment)} items-center mb-3 w-full ${horizontalAlignment !== 'middle' ? 'px-3 sm:px-8' : ''}`}>
|
||||||
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
<h2 className={`liquid-category-title text-2xl font-extrabold text-white ${horizontalAlignment === 'left' ? 'text-left' : horizontalAlignment === 'right' ? 'text-right' : 'text-center'} ${horizontalAlignment !== 'middle' ? 'w-full' : ''}`}>{category.name}</h2>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
@@ -53,13 +40,11 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-edit-action liquid-focus ml-2 shrink-0 transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
aria-label={`Edit ${category.name} category`}
|
aria-label={`Edit ${category.name} category`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PencilIcon size={14} />
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex flex-wrap ${getHorizontalAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
<div className={`flex flex-wrap ${getAlignmentClass(horizontalAlignment)} gap-5 sm:gap-6 px-1 sm:px-0`}>
|
||||||
{category.websites.map((website) => (
|
{category.websites.map((website) => (
|
||||||
<WebsiteTile
|
<WebsiteTile
|
||||||
key={website.id}
|
key={website.id}
|
||||||
@@ -73,13 +58,10 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddingWebsite(category)}
|
onClick={() => setAddingWebsite(category)}
|
||||||
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getAddTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
className={`liquid-surface liquid-control liquid-ghost-tile liquid-focus flex-col ${getTileSizeClass(tileSize)} transition-all duration-300 ease-spring transform ${isEditing ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
|
||||||
aria-label={`Add website to ${category.name}`}
|
aria-label={`Add website to ${category.name}`}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PlusIcon size={28} />
|
||||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
||||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm font-bold">Add</span>
|
<span className="text-sm font-bold">Add</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -88,4 +70,4 @@ const CategoryGroup: React.FC<CategoryGroupProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(CategoryGroup);
|
export default memo(CategoryGroup);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
|
import { PencilIcon } from '../icons';
|
||||||
|
|
||||||
interface EditButtonProps {
|
interface EditButtonProps {
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
@@ -13,13 +13,11 @@ const EditButton: React.FC<EditButtonProps> = ({ isEditing, onClick }) => {
|
|||||||
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
className={`liquid-surface liquid-control liquid-focus rounded-2xl px-3.5 py-3 text-xs font-bold ${isEditing ? 'pr-4' : ''}`}
|
||||||
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
aria-label={isEditing ? 'Finish editing' : 'Edit page'}
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
<PencilIcon size={16} />
|
||||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
|
||||||
</svg>
|
|
||||||
{isEditing ? 'Done' : ''}
|
{isEditing ? 'Done' : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditButton;
|
export default EditButton;
|
||||||
@@ -1,63 +1,17 @@
|
|||||||
import Clock from '../Clock';
|
import Clock from '../Clock';
|
||||||
import { Config } from '../../types';
|
import { Config } from '../../types';
|
||||||
|
import { getTitleSizeClass } from '../utils/styleUtils';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
config: Config;
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getClockSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-3xl';
|
|
||||||
case 'small':
|
|
||||||
return 'text-4xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-5xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-6xl';
|
|
||||||
default:
|
|
||||||
return 'text-5xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTitleSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-4xl';
|
|
||||||
case 'small':
|
|
||||||
return 'text-5xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-6xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-7xl';
|
|
||||||
default:
|
|
||||||
return 'text-6xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleSizeClass = (size: string) => {
|
|
||||||
switch (size) {
|
|
||||||
case 'tiny':
|
|
||||||
return 'text-lg';
|
|
||||||
case 'small':
|
|
||||||
return 'text-xl';
|
|
||||||
case 'medium':
|
|
||||||
return 'text-2xl';
|
|
||||||
case 'large':
|
|
||||||
return 'text-3xl';
|
|
||||||
default:
|
|
||||||
return 'text-2xl';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getClockSizeClass, getTitleSizeClass, getSubtitleSizeClass };
|
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ config }) => {
|
const Header: React.FC<HeaderProps> = ({ config }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{config.clock.enabled && (
|
{config.clock.enabled && (
|
||||||
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
<div className="absolute top-5 left-1/2 -translate-x-1/2 z-10 flex justify-center w-auto px-3 py-2">
|
||||||
<Clock config={config} getClockSizeClass={getClockSizeClass} />
|
<Clock config={config} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
<div className={`relative z-10 flex flex-col ${config.alignment === 'bottom' ? 'mt-auto' : ''} items-center`}>
|
||||||
@@ -75,4 +29,4 @@ const Header: React.FC<HeaderProps> = ({ config }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
checkChromeStorageLocalAvailable,
|
checkChromeStorageLocalAvailable,
|
||||||
removeWallpaperFromChromeStorageLocal,
|
removeWallpaperFromChromeStorageLocal,
|
||||||
} from '../utils/StorageLocalManager';
|
} from '../utils/StorageLocalManager';
|
||||||
|
import { getFileNameFromUrl } from '../utils/urlUtils';
|
||||||
|
|
||||||
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||||
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||||
@@ -44,16 +45,6 @@ const safeParse = (value: string | null): unknown => {
|
|||||||
const toStorageString = (value: unknown): string =>
|
const toStorageString = (value: unknown): string =>
|
||||||
typeof value === 'string' ? value : JSON.stringify(value);
|
typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
|
||||||
const getWallpaperNameFromUrl = (url: URL): string => {
|
|
||||||
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
|
||||||
if (!pathName) return url.hostname;
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(pathName);
|
|
||||||
} catch {
|
|
||||||
return pathName;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
@@ -121,7 +112,7 @@ export const ConfigurationService = {
|
|||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
}
|
}
|
||||||
const finalName = name.trim() || getWallpaperNameFromUrl(parsedUrl) || 'Wallpaper';
|
const finalName = name.trim() || getFileNameFromUrl(parsedUrl) || 'Wallpaper';
|
||||||
return { name: finalName, url: parsedUrl.href };
|
return { name: finalName, url: parsedUrl.href };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
// TypeScript interface for window.chrome
|
// TypeScript interface for window.chrome
|
||||||
|
import { getFileNameFromUrl } from './urlUtils';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
chrome?: {
|
chrome?: {
|
||||||
@@ -23,7 +25,6 @@ const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
|
|||||||
const getIconCacheKey = (sourceUrl: string): string =>
|
const getIconCacheKey = (sourceUrl: string): string =>
|
||||||
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
|
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if chrome.storage.local is available and caches the result.
|
* Checks if chrome.storage.local is available and caches the result.
|
||||||
*/
|
*/
|
||||||
@@ -37,54 +38,60 @@ export function checkChromeStorageLocalAvailable(): boolean {
|
|||||||
return isChromeStorageLocalAvailable;
|
return isChromeStorageLocalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
type StorageResult = { [key: string]: string };
|
||||||
if (!checkChromeStorageLocalAvailable()) return null;
|
|
||||||
|
|
||||||
return new Promise<string | null>((resolve) => {
|
const chromeLocalCall = <T>(
|
||||||
|
operation: (callback: (result: T) => void) => void,
|
||||||
|
): Promise<T> =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
if (!window.chrome?.storage?.local) {
|
if (!window.chrome?.storage?.local) {
|
||||||
resolve(null);
|
reject(new Error('chrome.storage.local is not available'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
operation((result) => {
|
||||||
const key = getIconCacheKey(sourceUrl);
|
|
||||||
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
|
|
||||||
if (window.chrome?.runtime?.lastError) {
|
if (window.chrome?.runtime?.lastError) {
|
||||||
resolve(null);
|
reject(new Error(window.chrome.runtime.lastError.message));
|
||||||
return;
|
} else {
|
||||||
|
resolve(result);
|
||||||
}
|
}
|
||||||
resolve(result[key] || null);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
|
||||||
|
if (!checkChromeStorageLocalAvailable()) return null;
|
||||||
|
try {
|
||||||
|
const key = getIconCacheKey(sourceUrl);
|
||||||
|
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||||
|
window.chrome?.storage?.local?.get([key], cb),
|
||||||
|
);
|
||||||
|
return result[key] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
|
||||||
if (!checkChromeStorageLocalAvailable()) return false;
|
if (!checkChromeStorageLocalAvailable()) return false;
|
||||||
|
try {
|
||||||
return new Promise<boolean>((resolve) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (!window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, cb),
|
||||||
resolve(false);
|
);
|
||||||
return;
|
return true;
|
||||||
}
|
} catch {
|
||||||
|
return false;
|
||||||
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () {
|
}
|
||||||
resolve(!window.chrome?.runtime?.lastError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
|
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
|
||||||
if (!checkChromeStorageLocalAvailable()) return false;
|
if (!checkChromeStorageLocalAvailable()) return false;
|
||||||
|
try {
|
||||||
return new Promise<boolean>((resolve) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (!window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.remove(getIconCacheKey(sourceUrl), cb),
|
||||||
resolve(false);
|
);
|
||||||
return;
|
return true;
|
||||||
}
|
} catch {
|
||||||
|
return false;
|
||||||
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () {
|
}
|
||||||
resolve(!window.chrome?.runtime?.lastError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,22 +120,13 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
|
|||||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||||
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
throw new Error('Wallpaper URLs must use HTTP or HTTPS.');
|
||||||
}
|
}
|
||||||
finalName = finalName || parsedUrl.pathname.split('/').filter(Boolean).pop() || parsedUrl.hostname;
|
finalName = finalName || getFileNameFromUrl(parsedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (!window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
);
|
||||||
return;
|
return finalName;
|
||||||
}
|
|
||||||
window.chrome.storage.local.set({ [finalName]: url }, function () {
|
|
||||||
if (window.chrome?.runtime?.lastError) {
|
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).then(() => finalName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,19 +139,10 @@ export async function getWallpaperFromChromeStorageLocal(name: string): Promise<
|
|||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
return new Promise<string | null>((resolve, reject) => {
|
const result = await chromeLocalCall<StorageResult>((cb) =>
|
||||||
if (window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.get([name], cb),
|
||||||
window.chrome.storage.local.get([name], function (result: { [key: string]: string }) {
|
);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
return result[name] || null;
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve(result[name] || null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,17 +155,7 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
|
|||||||
if (!checkChromeStorageLocalAvailable()) {
|
if (!checkChromeStorageLocalAvailable()) {
|
||||||
throw new Error('chrome.storage.local is not available');
|
throw new Error('chrome.storage.local is not available');
|
||||||
}
|
}
|
||||||
return new Promise<void>((resolve, reject) => {
|
await chromeLocalCall<void>((cb) =>
|
||||||
if (window.chrome?.storage?.local) {
|
window.chrome?.storage?.local?.remove(name, cb),
|
||||||
window.chrome.storage.local.remove(name, function () {
|
);
|
||||||
if (window.chrome?.runtime?.lastError) {
|
}
|
||||||
reject(new Error(window.chrome.runtime.lastError.message));
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reject(new Error('chrome.storage.local is not available'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,19 @@ import {
|
|||||||
} from './StorageLocalManager';
|
} from './StorageLocalManager';
|
||||||
|
|
||||||
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
const MAX_CACHED_ICON_BYTES = 256 * 1024;
|
||||||
|
const MAX_RESOLVED_ICONS = 50;
|
||||||
const resolvedIconCache = new Map<string, string>();
|
const resolvedIconCache = new Map<string, string>();
|
||||||
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
const iconCacheLookups = new Map<string, Promise<string | null>>();
|
||||||
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
const iconCacheRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
const rememberResolvedIcon = (iconUrl: string, dataUrl: string): void => {
|
||||||
|
resolvedIconCache.set(iconUrl, dataUrl);
|
||||||
|
if (resolvedIconCache.size > MAX_RESOLVED_ICONS) {
|
||||||
|
const oldest = resolvedIconCache.keys().next().value;
|
||||||
|
if (oldest !== undefined) resolvedIconCache.delete(oldest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
const isDataUrl = (value: string): boolean => value.startsWith('data:');
|
||||||
|
|
||||||
const isCacheableIconUrl = (value: string): boolean => {
|
const isCacheableIconUrl = (value: string): boolean => {
|
||||||
@@ -38,9 +47,14 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
|
|||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getWebsiteIcon(url: string): Promise<string> {
|
async function getWebsiteIcon(rawUrl: string): Promise<string> {
|
||||||
|
let targetUrl = rawUrl.trim();
|
||||||
|
if (targetUrl && !/^https?:\/\//i.test(targetUrl)) {
|
||||||
|
targetUrl = `https://${targetUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(targetUrl);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
@@ -48,7 +62,7 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
|||||||
if (appleTouchIcon) {
|
if (appleTouchIcon) {
|
||||||
const href = appleTouchIcon.getAttribute('href');
|
const href = appleTouchIcon.getAttribute('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
return new URL(href, url).href;
|
return new URL(href, targetUrl).href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,15 +70,19 @@ async function getWebsiteIcon(url: string): Promise<string> {
|
|||||||
if (iconLink) {
|
if (iconLink) {
|
||||||
const href = iconLink.getAttribute('href');
|
const href = iconLink.getAttribute('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
return new URL(href, url).href;
|
return new URL(href, targetUrl).href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching and parsing HTML for icon:', error);
|
console.error('Error fetching and parsing HTML for icon:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
|
try {
|
||||||
|
const hostname = new URL(targetUrl).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=128`;
|
||||||
|
} catch {
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(rawUrl)}&sz=128`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
||||||
@@ -80,7 +98,7 @@ async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
|
|||||||
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
|
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
|
||||||
.then((cachedIcon) => {
|
.then((cachedIcon) => {
|
||||||
if (isValidCachedIcon(cachedIcon)) {
|
if (isValidCachedIcon(cachedIcon)) {
|
||||||
resolvedIconCache.set(iconUrl, cachedIcon);
|
rememberResolvedIcon(iconUrl, cachedIcon);
|
||||||
return cachedIcon;
|
return cachedIcon;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -121,7 +139,7 @@ async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataUrl = await blobToDataUrl(blob);
|
const dataUrl = await blobToDataUrl(blob);
|
||||||
resolvedIconCache.set(iconUrl, dataUrl);
|
rememberResolvedIcon(iconUrl, dataUrl);
|
||||||
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const SIZE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'tiny', label: 'Tiny' },
|
||||||
|
{ value: 'small', label: 'Small' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'large', label: 'Large' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TILE_SIZE_CLASSES: Record<string, string> = {
|
||||||
|
small: 'w-28 h-28',
|
||||||
|
medium: 'w-32 h-32',
|
||||||
|
large: 'w-36 h-36',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTileSizeClass = (size: string | undefined): string =>
|
||||||
|
TILE_SIZE_CLASSES[size ?? ''] ?? 'w-32 h-32';
|
||||||
|
|
||||||
|
export const ICON_PIXEL_SIZES: Record<string, number> = {
|
||||||
|
small: 34,
|
||||||
|
medium: 42,
|
||||||
|
large: 48,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIconPixelSize = (size: string | undefined): number =>
|
||||||
|
ICON_PIXEL_SIZES[size ?? ''] ?? 40;
|
||||||
|
|
||||||
|
export const ICON_LOADING_PIXEL_SIZES: Record<string, number> = {
|
||||||
|
small: 24,
|
||||||
|
medium: 32,
|
||||||
|
large: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getIconLoadingPixelSize = (size: string | undefined): number =>
|
||||||
|
ICON_LOADING_PIXEL_SIZES[size ?? ''] ?? 32;
|
||||||
|
|
||||||
|
export const CLOCK_SIZE_CLASSES: Record<string, string> = {
|
||||||
|
tiny: 'text-3xl',
|
||||||
|
small: 'text-4xl',
|
||||||
|
medium: 'text-5xl',
|
||||||
|
large: 'text-6xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClockSizeClass = (size: string): string =>
|
||||||
|
CLOCK_SIZE_CLASSES[size] ?? 'text-5xl';
|
||||||
|
|
||||||
|
export const TITLE_SIZE_CLASSES: Record<string, string> = {
|
||||||
|
tiny: 'text-4xl',
|
||||||
|
small: 'text-5xl',
|
||||||
|
medium: 'text-6xl',
|
||||||
|
large: 'text-7xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTitleSizeClass = (size: string): string =>
|
||||||
|
TITLE_SIZE_CLASSES[size] ?? 'text-6xl';
|
||||||
|
|
||||||
|
export const ALIGNMENT_CLASSES: Record<string, string> = {
|
||||||
|
top: 'justify-start',
|
||||||
|
left: 'justify-start',
|
||||||
|
middle: 'justify-center',
|
||||||
|
bottom: 'justify-end',
|
||||||
|
right: 'justify-end',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAlignmentClass = (alignment: string): string =>
|
||||||
|
ALIGNMENT_CLASSES[alignment] ?? 'justify-center';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const getFileNameFromUrl = (url: URL): string => {
|
||||||
|
const pathName = url.pathname.split('/').filter(Boolean).pop();
|
||||||
|
if (!pathName) return url.hostname;
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(pathName);
|
||||||
|
} catch {
|
||||||
|
return pathName;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const MIN_WALLPAPER_FREQUENCY_HOURS = 1;
|
||||||
|
export const MAX_WALLPAPER_FREQUENCY_HOURS = 48;
|
||||||
|
export const DEFAULT_WALLPAPER_FREQUENCY_HOURS = 24;
|
||||||
|
|
||||||
|
export const getWallpaperFrequencyHours = (frequency: string): number => {
|
||||||
|
if (!frequency) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||||
|
const match = frequency.match(/^(\d+)(h|d)$/);
|
||||||
|
if (!match) return DEFAULT_WALLPAPER_FREQUENCY_HOURS;
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const hours = match[2] === 'd' ? value * 24 : value;
|
||||||
|
return Math.min(MAX_WALLPAPER_FREQUENCY_HOURS, Math.max(MIN_WALLPAPER_FREQUENCY_HOURS, hours));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWallpaperFrequencyMs = (frequency: string): number =>
|
||||||
|
getWallpaperFrequencyHours(frequency) * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export const formatWallpaperFrequency = (hours: number): string =>
|
||||||
|
`${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
||||||
|
|
||||||
|
export const getRandomWallpaperIndex = (wallpaperCount: number, currentIndex: number): number => {
|
||||||
|
if (wallpaperCount <= 1) return 0;
|
||||||
|
const offset = Math.floor(Math.random() * (wallpaperCount - 1)) + 1;
|
||||||
|
return (currentIndex + offset) % wallpaperCount;
|
||||||
|
};
|
||||||
+16
-8
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
project_name: Vision Start
|
project_name: Vision Start
|
||||||
date: 2026-08-07
|
date: 2026-08-11
|
||||||
type: general_overview
|
type: general_overview
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,13 +65,15 @@ The startpage is composed of widgets and a configuration panel:
|
|||||||
|
|
||||||
Performance notes:
|
Performance notes:
|
||||||
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
- Website tile icons check a deterministic `vision-start:icon:<encoded-source-url>` entry in `chrome.storage.local` before loading the external URL. Cache population uses ordinary CORS-enabled `fetch`, deduplicates in-flight requests, stores only `image/*` responses up to 256KiB, and never intercepts or proxies outside requests.
|
||||||
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load.
|
- Modals (`ConfigurationModal`, `WebsiteEditModal`, `CategoryEditModal`) are code-split via `React.lazy` + `Suspense` and only loaded when opened. `ConfigurationModal` is the heaviest chunk (it pulls in `@hello-pangea/dnd` via `ServerWidgetTab`); the rest of `@hello-pangea/dnd` is isolated from the initial load. `WebsiteEditModal` and `CategoryEditModal` share a common `ModalShell` component.
|
||||||
- `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.
|
- `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.
|
- `Clock` updates on the minute boundary (one `setTimeout` → `setInterval(60_000)`) instead of every second.
|
||||||
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
- `jsping` cancels its 5s timeout on image resolve/error and nulls the `Image` handlers, preventing leaks across ping cycles.
|
||||||
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
- `ServerWidget` batches pending-status updates into one `setState` and depends on a stable servers signature (ids+addresses) so unrelated config edits don't restart pings.
|
||||||
- Icon metadata (`/icon-metadata.json`) is module-level cached and hydrated into each icon-picker instance, fetched lazily on first focus of the icon field with `cache: 'force-cache'`, filter debounced ~150ms, and color variants are expanded lazily during filtering rather than upfront.
|
- Icon metadata (`/icon-metadata.json`) is 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. The module-level metadata cache is released when the picker modal unmounts (the browser HTTP cache makes reopening cheap), and the dashboard-icon URL for a picker entry is derived from one shared helper.
|
||||||
- `Wallpaper` caches resolved wallpaper URLs in a module-level `Map`; its image transition and readability overlay classes live in `index.css`.
|
- `Wallpaper` resolves wallpaper URLs through a module-level `Map` bounded to the last 3 entries — so uploaded base64 wallpapers (up to ~4.5MB strings) are not retained in memory for the page's lifetime; its image transition and readability overlay classes live in `index.css`. Wallpaper rotation and frequency parsing (`h`/`d` → hours/ms) share helpers with the Theme tab via `components/utils/wallpaperUtils.ts`.
|
||||||
|
- The website icon service keeps an in-memory resolved data-URL cache bounded to 50 entries (FIFO eviction) to avoid unbounded growth from many tiles.
|
||||||
|
- Shared styling/frequency helpers live in `components/utils/styleUtils.ts` (tile/clock/title size classes, icon pixel sizes, alignment classes, size presets), `wallpaperUtils.ts`, and `urlUtils.ts` (URL→filename extraction, also used by wallpaper storage). Range sliders use the shared `RangeSlider` component; repeated glyphs (pencil, plus, trash, chevrons) use `components/icons.tsx`.
|
||||||
|
|
||||||
Planned / To-do (tracked in `README.md`):
|
Planned / To-do (tracked in `README.md`):
|
||||||
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
- Dynamic Weather widget, Search Bar widget, draggable/resizable grid system, Notes/Scratchpad widget, theming (light/dark, accent colors, wallpaper-derived accents, minimal feel toggle), and a general "refactor everything" note.
|
||||||
@@ -98,9 +100,11 @@ vision-start/
|
|||||||
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
│ ├── WebsiteEditModal.tsx # Add/edit a website (icon picker inside)
|
||||||
│ ├── CategoryEditModal.tsx # Add/edit a category
|
│ ├── CategoryEditModal.tsx # Add/edit a category
|
||||||
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
│ ├── ConfigurationModal.tsx # Tabbed settings drawer with Export/Import
|
||||||
|
│ ├── ModalShell.tsx # Shared centered-modal shell (backdrop, title, footer buttons)
|
||||||
│ ├── ServerWidget.tsx # Bottom server status pill
|
│ ├── ServerWidget.tsx # Bottom server status pill
|
||||||
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
│ ├── Dropdown.tsx # Reusable glassy dropdown (single/multi select)
|
||||||
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
│ ├── ToggleSwitch.tsx # Reusable toggle switch
|
||||||
|
│ ├── icons.tsx # Shared inline SVG icons (pencil, plus, trash, chevrons)
|
||||||
│ │
|
│ │
|
||||||
│ ├── layout/
|
│ ├── layout/
|
||||||
│ │ ├── Header.tsx # Renders Clock + Title
|
│ │ ├── Header.tsx # Renders Clock + Title
|
||||||
@@ -112,7 +116,8 @@ vision-start/
|
|||||||
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
│ │ ├── GeneralTab.tsx # Title, sizes, alignment, tile size
|
||||||
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
│ │ ├── ThemeTab.tsx # Background selection, wallpaper cadence, wallpaper mgmt, blur/brightness/opacity
|
||||||
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
│ │ ├── ClockTab.tsx # Clock enable/size/font/format
|
||||||
│ │ └── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
│ │ ├── ServerWidgetTab.tsx # Server widget enable/ping/servers (drag-to-reorder)
|
||||||
|
│ │ └── RangeSlider.tsx # Shared labeled range slider (with progress fill)
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
│ │ └── ConfigurationService.ts # DEFAULT_CONFIG, load/save config & wallpapers, add/delete wallpaper, export/import config, reset wallpaper state
|
||||||
@@ -121,7 +126,10 @@ vision-start/
|
|||||||
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
│ ├── baseWallpapers.ts # Built-in wallpaper catalog (imgur/wallpapershome URLs)
|
||||||
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
│ ├── iconService.ts # icon discovery plus CORS-safe website icon cache lookup/population
|
||||||
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
│ ├── jsping.js # Image-load based "ping" with 5s timeout (used by ServerWidget)
|
||||||
│ └── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
│ ├── StorageLocalManager.ts # chrome.storage.local wrappers + availability check; wallpaper and icon cache storage
|
||||||
|
│ ├── styleUtils.ts # Shared tile/clock/title size classes, icon pixel sizes, alignment classes, size presets
|
||||||
|
│ ├── wallpaperUtils.ts # Wallpaper frequency parsing (h/d → hours/ms) and random-index helpers
|
||||||
|
│ └── urlUtils.ts # URL → filename extraction (shared by wallpaper storage paths)
|
||||||
│
|
│
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── favicon.ico
|
│ ├── favicon.ico
|
||||||
@@ -232,7 +240,7 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
## 8. Notable Behaviors & Quirks
|
## 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.
|
- **`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, both of which share the `ModalShell` component.
|
||||||
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
- **`@hello-pangea/dnd`** is used only in `ServerWidgetTab.tsx` (server reorder), which itself is imported by the lazy-loaded `ConfigurationModal`, so it lives in a separate chunk and is absent from the initial page load. `WebsiteTile` moves tiles within their own category via simple left/right buttons (no cross-category movement), not drag-and-drop.
|
||||||
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
- **Chrome storage is optional.** `StorageLocalManager` checks availability once (`checkChromeStorageLocalAvailable`) and caches it. Remote wallpaper URLs work without it because their URLs live in `userWallpapers`; file upload is gated off when unavailable, and `addWallpaperToChromeStorageLocal` throws if called without it.
|
||||||
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
- **Wallpaper rotation** is time-based, evaluated on render/mount rather than via a timer. It reads `wallpaperState` from `localStorage`, selects a random non-current wallpaper when the frequency window has elapsed, and writes its index back; the frequency is clamped to 1–48 hours, while older saved values like `1d`/`2d` still resolve to their hour equivalents. The renderer clamps `currentIndex` to the valid range of the current selection and walks the list forward to find a wallpaper whose data actually resolves (so deleting the currently-displayed wallpaper, or shrinking the selection, never leaves the background blank); if no wallpaper resolves, the background layer is hidden. When the selection becomes empty, `wallpaperState` is reset and the background is hidden. A manual "Random Wallpaper" button in the Theme tab also picks a random non-current wallpaper and bumps a `wallpaperVersion` nonce in `App.tsx` that retriggers the renderer.
|
||||||
@@ -266,4 +274,4 @@ External assets fetched at build time by `scripts/prepare_release.sh`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Last updated: 2026-08-07. Generated as a general project overview; not a coding-style guide._
|
_Last updated: 2026-08-11. Generated as a general project overview; not a coding-style guide._
|
||||||
|
|||||||
Reference in New Issue
Block a user