fixing small issues in the project
This commit is contained in:
1 parent
032d2a100a
commit
99dbea0615
17 files changed
+166
-55
No files matched your search
@@ -18,7 +18,8 @@ const CategoryEditModal: React.FC<CategoryEditModalProps> = ({ category, edit, o
|
||||
title={edit ? 'Edit Category' : 'Add Category'}
|
||||
edit={edit}
|
||||
onClose={onClose}
|
||||
onSave={() => onSave(name)}
|
||||
onSave={() => onSave(name.trim())}
|
||||
canSave={name.trim() !== ''}
|
||||
onDelete={edit ? onDelete : undefined}
|
||||
>
|
||||
<input
|
||||
|
||||
@@ -63,6 +63,17 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
closeTimer.current = setTimeout(onClose, 250);
|
||||
};
|
||||
|
||||
const handleCloseRef = useRef(handleClose);
|
||||
handleCloseRef.current = handleClose;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleConfigChange = (updates: Partial<Config>) => {
|
||||
setConfig((prev) => ({ ...prev, ...updates }));
|
||||
};
|
||||
@@ -139,7 +150,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="configuration-title">
|
||||
<div
|
||||
className={`liquid-modal-backdrop fixed inset-0 transition-opacity duration-200 ease-ios ${
|
||||
isVisible ? 'opacity-100' : 'opacity-0'
|
||||
@@ -153,7 +164,7 @@ const ConfigurationModal: React.FC<ConfigurationModalProps> = ({
|
||||
}`}
|
||||
>
|
||||
<div className="p-6 sm:p-8 flex-grow overflow-y-auto">
|
||||
<h2 className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
|
||||
<h2 id="configuration-title" className="liquid-title-text text-3xl font-extrabold mb-6">Configuration</h2>
|
||||
|
||||
<div className="liquid-surface grid grid-cols-2 sm:grid-cols-4 gap-1 rounded-2xl p-1 mb-7">
|
||||
{tabs.map((tab) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useId, useRef } from 'react';
|
||||
|
||||
interface ModalShellProps {
|
||||
title: string;
|
||||
@@ -6,10 +6,23 @@ interface ModalShellProps {
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void;
|
||||
canSave?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, children }) => {
|
||||
const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, onDelete, canSave = true, children }) => {
|
||||
const titleId = useId();
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
@@ -18,8 +31,8 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
|
||||
|
||||
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="liquid-panel liquid-modal-card rounded-3xl p-6 sm:p-8 w-full max-w-lg text-white" role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||
<h2 id={titleId} 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>
|
||||
@@ -30,7 +43,11 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onSave} className="liquid-button liquid-button-success liquid-focus py-2.5 px-5">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
className="liquid-button liquid-button-success liquid-focus py-2.5 px-5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button onClick={onClose} className="liquid-button liquid-button-secondary liquid-focus py-2.5 px-5">
|
||||
@@ -43,4 +60,4 @@ const ModalShell: React.FC<ModalShellProps> = ({ title, edit, onClose, onSave, o
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalShell;
|
||||
export default ModalShell;
|
||||
@@ -144,7 +144,7 @@ const Wallpaper: React.FC<WallpaperProps> = ({ wallpaperNames, blur, brightness,
|
||||
<div
|
||||
className="wallpaper-layer wallpaper-transition"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundImage: `url(${JSON.stringify(imageUrl)})`,
|
||||
filter: `blur(${blur}px) brightness(${brightness / 100})`,
|
||||
opacity: opacity / 100,
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Website } from '../types';
|
||||
import { getWebsiteIcon } from './utils/iconService';
|
||||
import { normalizeWebsiteUrl } from './utils/urlUtils';
|
||||
import ModalShell from './ModalShell';
|
||||
|
||||
interface WebsiteEditModalProps {
|
||||
@@ -98,6 +99,15 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
}
|
||||
}, [iconQuery, iconMetadata]);
|
||||
|
||||
const handleSave = () => {
|
||||
const normalizedUrl = normalizeWebsiteUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
alert('Please enter a valid website URL (http, https, ftp, or mailto).');
|
||||
return;
|
||||
}
|
||||
onSave({ id: website?.id, name: name.trim(), url: normalizedUrl, icon });
|
||||
};
|
||||
|
||||
const fetchIcon = async () => {
|
||||
if (url) {
|
||||
const fetchedIcon = await getWebsiteIcon(url);
|
||||
@@ -110,7 +120,8 @@ const WebsiteEditModal: React.FC<WebsiteEditModalProps> = ({ website, edit, onCl
|
||||
title={edit ? 'Edit Website' : 'Add Website'}
|
||||
edit={edit}
|
||||
onClose={onClose}
|
||||
onSave={() => onSave({ id: website?.id, name, url, icon })}
|
||||
onSave={handleSave}
|
||||
canSave={name.trim() !== '' && url.trim() !== ''}
|
||||
onDelete={edit ? onDelete : undefined}
|
||||
>
|
||||
<div className="flex justify-center mb-4">
|
||||
|
||||
@@ -40,7 +40,6 @@ const ClockTab: React.FC<ClockTabProps> = ({ config, onChange }) => {
|
||||
onChange={(e) => updateClock({ font: e.target.value as string })}
|
||||
options={[
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: `'Orbitron', sans-serif`, label: 'Orbitron' },
|
||||
{ value: 'monospace', label: 'Monospace' },
|
||||
{ value: 'cursive', label: 'Cursive' },
|
||||
]}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Config, Server } from '../../types';
|
||||
import RangeSlider from './RangeSlider';
|
||||
import { TrashIcon } from '../icons';
|
||||
import { createId } from '../utils/idUtils';
|
||||
import { parseServerAddress } from '../utils/urlUtils';
|
||||
|
||||
interface ServerWidgetTabProps {
|
||||
config: Config;
|
||||
@@ -21,6 +22,12 @@ const ServerWidgetTab: React.FC<ServerWidgetTabProps> = ({ config, onChange }) =
|
||||
|
||||
const handleAddServer = () => {
|
||||
if (newServerName.trim() === '' || newServerAddress.trim() === '') return;
|
||||
try {
|
||||
parseServerAddress(newServerAddress);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error && error.message.startsWith('Server') ? error.message : 'Please enter a valid server address, e.g. 192.168.1.10:8080 or https://example.com.');
|
||||
return;
|
||||
}
|
||||
const newServer: Server = {
|
||||
id: createId(),
|
||||
name: newServerName.trim(),
|
||||
|
||||
@@ -121,7 +121,10 @@ const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
>
|
||||
<span className="truncate">{wallpaper.name}</span>
|
||||
<button
|
||||
onClick={() => onDeleteWallpaper(wallpaper)}
|
||||
onClick={() => {
|
||||
if (!wallpaper.url && !confirm(`Delete "${wallpaper.name}"? Uploaded wallpapers are not included in exports and cannot be recovered.`)) return;
|
||||
void onDeleteWallpaper(wallpaper);
|
||||
}}
|
||||
className="liquid-edit-action liquid-focus text-red-300 hover:text-red-100"
|
||||
aria-label={`Delete ${wallpaper.name}`}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
checkChromeStorageLocalAvailable,
|
||||
removeWallpaperFromChromeStorageLocal,
|
||||
} from '../utils/StorageLocalManager';
|
||||
import { getFileNameFromUrl } from '../utils/urlUtils';
|
||||
import { getFileNameFromUrl, normalizeWebsiteUrl } from '../utils/urlUtils';
|
||||
import { getWallpaperFrequencyHours } from '../utils/wallpaperUtils';
|
||||
|
||||
export const CLOCK_FONTS = ['Helvetica', 'monospace', 'cursive'];
|
||||
|
||||
const REQUIRED_LOCAL_STORAGE_KEYS = ['config', 'categories', 'userWallpapers', 'wallpaperState'] as const;
|
||||
type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||
@@ -14,7 +17,7 @@ type RequiredLocalStorageKey = typeof REQUIRED_LOCAL_STORAGE_KEYS[number];
|
||||
export const DEFAULT_CONFIG: Config = {
|
||||
title: 'Vision Start',
|
||||
currentWallpapers: ['Beach'],
|
||||
wallpaperFrequency: '1d',
|
||||
wallpaperFrequency: '24h',
|
||||
wallpaperBlur: 0,
|
||||
wallpaperBrightness: 108,
|
||||
wallpaperOpacity: 96,
|
||||
@@ -78,6 +81,7 @@ const hasStrings = (value: unknown, keys: string[]): value is Record<string, str
|
||||
export const normalizeConfig = (stored: unknown): Config => {
|
||||
const config = deepMerge(DEFAULT_CONFIG, stored);
|
||||
config.currentWallpapers = [...new Set(config.currentWallpapers.filter(name => typeof name === 'string' && name.length > 0))];
|
||||
config.wallpaperFrequency = `${getWallpaperFrequencyHours(config.wallpaperFrequency)}h`;
|
||||
config.wallpaperBlur = boundedNumber(config.wallpaperBlur, DEFAULT_CONFIG.wallpaperBlur, 0, 50);
|
||||
config.wallpaperBrightness = boundedNumber(config.wallpaperBrightness, DEFAULT_CONFIG.wallpaperBrightness, 0, 200);
|
||||
config.wallpaperOpacity = boundedNumber(config.wallpaperOpacity, DEFAULT_CONFIG.wallpaperOpacity, 1, 100);
|
||||
@@ -86,6 +90,7 @@ export const normalizeConfig = (stored: unknown): Config => {
|
||||
config.alignment = choice(config.alignment, ['top', 'middle', 'bottom'], DEFAULT_CONFIG.alignment);
|
||||
config.horizontalAlignment = choice(config.horizontalAlignment, ['left', 'middle', 'right'], DEFAULT_CONFIG.horizontalAlignment);
|
||||
config.clock.size = choice(config.clock.size, ['tiny', 'small', 'medium', 'large'], DEFAULT_CONFIG.clock.size);
|
||||
config.clock.font = choice(config.clock.font, CLOCK_FONTS, DEFAULT_CONFIG.clock.font);
|
||||
config.clock.format = choice(config.clock.format, ['HH:mm', 'h:mm A'], DEFAULT_CONFIG.clock.format);
|
||||
config.serverWidget.pingFrequency = boundedNumber(config.serverWidget.pingFrequency, DEFAULT_CONFIG.serverWidget.pingFrequency, 5, 60);
|
||||
const serverIds = new Set<string>();
|
||||
@@ -110,8 +115,10 @@ export const normalizeCategories = (stored: unknown): Category[] => {
|
||||
name: category.name,
|
||||
websites: websites.flatMap(website => {
|
||||
if (!hasStrings(website, ['id', 'name', 'url']) || !website.id || websiteIds.has(website.id)) return [];
|
||||
const url = normalizeWebsiteUrl(website.url);
|
||||
if (!url) return [];
|
||||
websiteIds.add(website.id);
|
||||
return [{ id: website.id, name: website.name, url: website.url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
|
||||
return [{ id: website.id, name: website.name, url, icon: typeof website.icon === 'string' ? website.icon : '', categoryId: category.id }];
|
||||
}),
|
||||
}];
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ declare global {
|
||||
storage?: {
|
||||
local?: {
|
||||
set: (items: object, callback?: () => void) => void;
|
||||
get: (keys: string[] | string, callback: (items: { [key: string]: string }) => void) => void;
|
||||
get: (keys: string[] | string | null, callback: (items: { [key: string]: string }) => void) => void;
|
||||
remove: (keys: string | string[], callback?: () => void) => void;
|
||||
};
|
||||
};
|
||||
@@ -94,6 +94,24 @@ export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string):
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneCachedIconsFromChromeStorageLocal(keepSourceUrls: string[]): Promise<number> {
|
||||
if (!checkChromeStorageLocalAvailable()) return 0;
|
||||
try {
|
||||
const keep = new Set(keepSourceUrls.map(getIconCacheKey));
|
||||
const items = await chromeLocalCall<StorageResult>((cb) =>
|
||||
window.chrome?.storage?.local?.get(null, cb),
|
||||
);
|
||||
const stale = Object.keys(items).filter(key => key.startsWith(ICON_CACHE_KEY_PREFIX) && !keep.has(key));
|
||||
if (stale.length === 0) return 0;
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.remove(stale, cb),
|
||||
);
|
||||
return stale.length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new wallpaper to chrome.storage.local.
|
||||
* File uploads are stored as base64 while remote wallpapers remain URLs.
|
||||
@@ -123,9 +141,16 @@ export async function addWallpaperToChromeStorageLocal(name: string, url: string
|
||||
finalName = finalName || getFileNameFromUrl(parsedUrl);
|
||||
}
|
||||
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
|
||||
);
|
||||
try {
|
||||
await chromeLocalCall<void>((cb) =>
|
||||
window.chrome?.storage?.local?.set({ [finalName]: url }, cb),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /quota/i.test(error.message)) {
|
||||
throw new Error('Not enough extension storage for this wallpaper. Delete an uploaded wallpaper and try again.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return finalName;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { parseServerAddress } from './urlUtils';
|
||||
|
||||
async function ping(url: string, multiplier = 1, signal?: AbortSignal): Promise<number> {
|
||||
const address = url.trim();
|
||||
const target = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(address) ? address : `http://${address}`);
|
||||
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
throw new Error('Server addresses must use HTTP or HTTPS.');
|
||||
}
|
||||
const target = parseServerAddress(url);
|
||||
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
|
||||
@@ -6,4 +6,29 @@ export const getFileNameFromUrl = (url: URL): string => {
|
||||
} catch {
|
||||
return pathName;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:/i;
|
||||
const AUTHORITY_SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
|
||||
const ALLOWED_WEBSITE_PROTOCOLS = new Set(['http:', 'https:', 'ftp:', 'mailto:']);
|
||||
|
||||
export const normalizeWebsiteUrl = (raw: string): string | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const hasScheme = AUTHORITY_SCHEME_PATTERN.test(trimmed) || (SCHEME_PATTERN.test(trimmed) && !/^[^:/]+:\d/.test(trimmed));
|
||||
try {
|
||||
const url = new URL(hasScheme ? trimmed : `https://${trimmed}`);
|
||||
return ALLOWED_WEBSITE_PROTOCOLS.has(url.protocol) ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseServerAddress = (address: string): URL => {
|
||||
const trimmed = address.trim();
|
||||
const target = new URL(AUTHORITY_SCHEME_PATTERN.test(trimmed) ? trimmed : `http://${trimmed}`);
|
||||
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
||||
throw new Error('Server addresses must use HTTP or HTTPS.');
|
||||
}
|
||||
return target;
|
||||
};
|
||||
Reference in new issue
Block a user