First refactor without AI

This commit is contained in:
Your Name
2025-03-15 18:32:10 -04:00
commit ab5380a399
25 changed files with 1661 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { NewTab } from './components/NewTab';
export const App: React.FC = () => {
return <NewTab />;
};
+81
View File
@@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { ClockWidgetData, WidgetExport } from '../types';
interface ClockWidgetProps {
widget: ClockWidgetData;
updateWidget: (id: string, update: Partial<ClockWidgetData>) => void
}
export const ClockWidgetComponent: React.FC<ClockWidgetProps> = ({ widget, updateWidget }) => {
const [time, setTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const formatTime = () => {
const options: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
timeZone: widget.timezone,
hour12: !widget.use24Hour
};
if (widget.showSeconds) {
options.second = '2-digit';
}
return new Intl.DateTimeFormat('en-US', options).format(time);
};
return <div className="clock-time">{formatTime()}</div>;
};
export const ClockWidgetSettings = ({ widget, updateWidget }: ClockWidgetProps) => {
const handleToggleSeconds = (e: React.ChangeEvent<HTMLInputElement>) => {
updateWidget(widget.id, { showSeconds: e.target.checked });
};
const handleToggle24Hour = (e: React.ChangeEvent<HTMLInputElement>) => {
updateWidget(widget.id, { use24Hour: e.target.checked });
};
const handleTimezoneChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateWidget(widget.id, { timezone: e.target.value });
};
return (
<>
<div className="seconds-toggle">
<input
type="checkbox"
checked={widget.showSeconds}
onChange={handleToggleSeconds}
/>
Show seconds
</div>
<div className="hours-toggle">
<input
type="checkbox"
checked={widget.use24Hour}
onChange={handleToggle24Hour}
/>
Use 24-hour format
</div>
<label>
Timezone:
<select value={widget.timezone} onChange={handleTimezoneChange}>
{Intl.supportedValuesOf('timeZone').map(tz => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</label>
</>
);
};
export const ClockWidget:WidgetExport = {
widget: ClockWidgetComponent,
settings: ClockWidgetSettings
}
+12
View File
@@ -0,0 +1,12 @@
type CogwheelIconProps = {
size: number;
}
export function CogwheelIcon({size}: CogwheelIconProps) {
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l-.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l-.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v-.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l-.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
)
}
+73
View File
@@ -0,0 +1,73 @@
import React, { useState, useEffect } from 'react';
import { useAtomValue } from 'jotai'
import { WidgetFactory } from './WidgetFactory';
import { SettingsPanel } from './SettingsPanel';
import { loadableWidgetsState } from '../state/widget.state';
import { showGridState } from '../state/showGrid.state';
import { WidgetData } from '../types';
import { loadableBackgroundImageState } from '../state/backgroundImage.state';
import { settingsService } from '../services/settingsService';
const GRID_COLS = 36;
const GRID_ROWS = 18;
export const NewTab: React.FC = () => {
const [gridSize, setGridSize] = useState({ width: window.innerWidth / GRID_COLS, height: window.innerHeight / GRID_ROWS });
const [widgets, setWidgets] = useState<WidgetData[]>([])
const [backgroundImage, setBackgroundImage] = useState<string>('')
const showGrid = useAtomValue(showGridState)
const loadingWidgets = useAtomValue(loadableWidgetsState)
const loadingBackgroundImage = useAtomValue(loadableBackgroundImageState)
useEffect(() => {
const handleResize = () => {
setGridSize({
width: window.innerWidth / GRID_COLS,
height: window.innerHeight / GRID_ROWS
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
})
useEffect(() => loadingBackgroundImage.state === 'hasData' ? setBackgroundImage(loadingBackgroundImage.data) : undefined, [loadingBackgroundImage]);
useEffect(() => loadingWidgets.state === 'hasData' ? setWidgets(loadingWidgets.data) : undefined, [loadingWidgets]);
useEffect(() => { document.body.style.backgroundImage = `url(${backgroundImage})` }, [backgroundImage]);
const updateWidget = <T extends WidgetData>(id: string, updates: Partial<T>) => {
const updatedWidgets = widgets.map(w =>
w.id === id ? { ...w, ...updates } : w
);
setWidgets(updatedWidgets);
settingsService.saveSetting('widgets', updatedWidgets)
console.log('Updated widget:', { id, updates })
};
return (
<>
<SettingsPanel
backgroundImage={backgroundImage}
setBackgroundImage={setBackgroundImage}
/>
{showGrid && (
<div className="grid-overlay">
{Array.from({ length: GRID_COLS + 1 }).map((_, i) => (
<div key={`v${i}`} className="grid-line vertical" style={{ left: `${(i / GRID_COLS) * 100}%` }} />
))}
{Array.from({ length: GRID_ROWS + 1 }).map((_, i) => (
<div key={`h${i}`} className="grid-line horizontal" style={{ top: `${(i / GRID_ROWS) * 100}%` }} />
))}
</div>
)}
{widgets.map(widget => (
<WidgetFactory
key={widget.id}
widget={widget}
gridSize={gridSize}
updateWidget={updateWidget}
/>
))}
</>
);
};
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { SearchWidgetData, WidgetExport } from '../types';
interface SearchWidgetProps {
widget: SearchWidgetData;
updateWidget: (id: string, update: Partial<SearchWidgetData>) => void
}
const searchEngines: { [key in SearchWidgetData['engine']]: string } = {
Google: 'https://www.google.com/search?q=',
Bing: 'https://www.bing.com/search?q=',
DuckDuckGo: 'https://duckduckgo.com/?q=',
Yandex: 'https://yandex.com/search/?text='
};
const SearchWidgetComponent: React.FC<SearchWidgetProps> = ({ widget }) => {
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const query = (e.currentTarget.elements.namedItem('query') as HTMLInputElement).value;
if (query) {
setTimeout(() => {
window.open(`${searchEngines[widget.engine]}${encodeURIComponent(query)}`, '_blank')
}, 200)
}
};
return (
<form onSubmit={handleSearch}>
<input type="text" name="query" placeholder="Search..." />
</form>
);
};
const SearchWidgetSettings = ({ widget, updateWidget }: SearchWidgetProps) => {
const handleEngineChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
updateWidget(widget.id, { engine: e.target.value as SearchWidgetData['engine'] });
};
return (
<label>
Search Engine:
<select value={widget.engine} onChange={handleEngineChange}>
{Object.keys(searchEngines).map((engine: any) =>
<option value={engine}>{engine}</option>
)}
</select>
</label>
);
};
export const SearchWidget:WidgetExport = {
widget: SearchWidgetComponent,
settings: SearchWidgetSettings
}
+76
View File
@@ -0,0 +1,76 @@
import React, { useState } from 'react';
import { CogwheelIcon } from './CogwheelIcon';
import { useAtom } from 'jotai';
import { draggableState } from '../state/draggable.state';
import { showGridState } from '../state/showGrid.state';
import { settingsService } from '../services/settingsService';
interface SettingsPanelProps {
backgroundImage: string,
setBackgroundImage: (url: string) => void
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ backgroundImage, setBackgroundImage }) => {
//const [backgroundUrl, setBackgroundUrl] = useAtom(backgroundImageState);
const [dragEnabled, setDragEnabled] = useAtom(draggableState);
const [showGrid, setShowGrid] = useAtom(showGridState);
const [isOpen, setIsOpen] = useState(false);
const handleSave = () => {
//settingsService.saveSetting('backgroundUrl', backgroundUrl)
settingsService.saveSetting('dragEnabled', dragEnabled)
settingsService.saveSetting('showGrid', showGrid)
setIsOpen(false);
};
const addWidget = (type: string) => {
//const newWidget = createBaseWidget(type, Math.floor(gridCols / 2), Math.floor(gridRows / 2), dragEnabled);
//saveSettings({ widgets: [...settings.widgets, newWidget] });
};
return (
<>
<button className="settings-toggle" onClick={() => setIsOpen(!isOpen)}>
<CogwheelIcon size={24} />
</button>
{isOpen && (
<div className="settings-modal">
<div className="modal-content">
<h2>Settings</h2>
<label>
Background URL:
<input
type="text"
value={backgroundImage}
onChange={(e) => setBackgroundImage(e.target.value)}
placeholder="Enter image URL"
/>
</label>
<label>
Drag Enabled:
<input
type="checkbox"
checked={dragEnabled}
onChange={(e) => setDragEnabled(e.target.checked)}
/>
</label>
<label>
Show Grid:
<input
type="checkbox"
checked={showGrid}
onChange={(e) => setShowGrid(e.target.checked)}
/>
</label>
<div className="widget-buttons">
<button onClick={() => addWidget('clock')}>Add Clock</button>
<button onClick={() => addWidget('search')}>Add Search</button>
</div>
<button className="close-modal" onClick={handleSave}>Save & Close</button>
</div>
</div>
)}
</>
);
};
+164
View File
@@ -0,0 +1,164 @@
import React, { useRef, useState, useEffect } from 'react';
import { WidgetData } from '../types';
import { CogwheelIcon } from './CogwheelIcon';
import { useAtom, useAtomValue } from 'jotai';
import { draggableState } from '../state/draggable.state';
interface WidgetProps {
widget: WidgetData;
gridSize: { width: number; height: number };
children: React.ReactNode;
settingsContent?: React.ReactNode;
updateWidget: (id: string, data: Partial<WidgetData>) => void
}
export const Widget: React.FC<WidgetProps> = ({ widget, gridSize, children, settingsContent, updateWidget }) => {
const widgetRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [widthInput, setWidthInput] = useState(widget.gridWidth);
const [heightInput, setHeightInput] = useState(widget.gridHeight);
const isDraggable = useAtomValue(draggableState)
const snapToGrid = (pos: number, grid: number) => Math.round(pos / grid);
const handleMouseDown = (e: React.MouseEvent) => {
if(!isDraggable || isSettingsOpen){
return
}
if (widgetRef.current && e.target === widgetRef.current) {
setIsDragging(true);
const rect = widgetRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
}
};
const handleMouseMove = (e: MouseEvent) => {
if (isDragging && widgetRef.current) {
e.preventDefault();
const newGridX = snapToGrid(e.clientX - dragOffset.x, gridSize.width);
const newGridY = snapToGrid(e.clientY - dragOffset.y, gridSize.height);
widgetRef.current.style.left = `${newGridX * gridSize.width}px`;
widgetRef.current.style.top = `${newGridY * gridSize.height}px`;
}
};
const handleMouseUp = () => {
if (isDragging && widgetRef.current) {
setIsDragging(false);
const rect = widgetRef.current.getBoundingClientRect();
const newGridX = snapToGrid(rect.left, gridSize.width);
const newGridY = snapToGrid(rect.top, gridSize.height);
updateWidget(widget.id, { gridX: newGridX, gridY: newGridY });
}
};
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
setIsSettingsOpen(false);
}
};
const stopPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
const handleSizeChange = () => {
const newWidth = Math.max(1, widthInput);
const newHeight = Math.max(1, heightInput);
updateWidget(widget.id, { gridWidth: newWidth, gridHeight: newHeight });
setWidthInput(newWidth);
setHeightInput(newHeight);
setIsSettingsOpen(false);
};
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = 'none';
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
};
}, [isDragging, dragOffset, gridSize]);
useEffect(() => {
if (isSettingsOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isSettingsOpen]);
useEffect(() => {
if (widgetRef.current && !isDragging) {
widgetRef.current.style.left = `${widget.gridX * gridSize.width}px`;
widgetRef.current.style.top = `${widget.gridY * gridSize.height}px`;
widgetRef.current.style.width = `${(widget.gridWidth) * gridSize.width}px`;
widgetRef.current.style.height = `${(widget.gridHeight) * gridSize.height}px`;
}
}, [widget.gridX, widget.gridY, widget.gridWidth, widget.gridHeight, gridSize, isDragging]);
const genericSettingsContent = (
<>
<label>
Width (grid cells):
<input
type="number"
min="1"
value={widthInput}
onChange={(e) => setWidthInput(parseInt(e.target.value) || 1)}
/>
</label>
<label>
Height (grid cells):
<input
type="number"
min="1"
value={heightInput}
onChange={(e) => setHeightInput(parseInt(e.target.value) || 1)}
/>
</label>
<button onClick={handleSizeChange}>Apply</button>
</>
);
return (
<div
ref={widgetRef}
className="container"
onMouseDown={handleMouseDown}
style={{ cursor: isDraggable && !isSettingsOpen ? 'grab' : 'default' }}
>
<button className="close-button" onClick={() => undefined /*removeWidget(widget.id)*/} onMouseDown={stopPropagation}>×</button>
<button
className="settings-button"
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
onMouseDown={stopPropagation}
>
<CogwheelIcon size={24} />
</button>
{children}
{isSettingsOpen && (
<div className="widget-settings-modal" onMouseDown={stopPropagation}>
<div ref={modalRef} className="widget-modal-content">
<button className="close-button" onClick={() => setIsSettingsOpen(false)} onMouseDown={stopPropagation}>×</button>
{settingsContent}
{genericSettingsContent}
</div>
</div>
)}
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { WidgetData, SearchWidgetData, ClockWidgetData, WidgetExport, WidgetTypes } from '../types';
import { Widget } from './Widget';
import { SearchWidget } from './SearchWidget';
import { ClockWidget } from './ClockWidget';
interface WidgetFactoryProps {
widget: WidgetData;
gridSize: { width: number; height: number };
updateWidget: (id: string, data: Partial<WidgetData>) => void
}
const WidgetMap: { [key in WidgetTypes]: WidgetExport } = {
'clock': ClockWidget,
'search': SearchWidget
}
export const WidgetFactory: React.FC<WidgetFactoryProps> = ({ widget, gridSize, updateWidget }) => {
const Wdgt = WidgetMap[widget.type]
const component = <Wdgt.widget widget={widget} updateWidget={updateWidget}></Wdgt.widget>
const settings = Wdgt.settings({ widget: widget, updateWidget })
return (
<Widget
widget={widget}
gridSize={gridSize}
settingsContent={settings}
updateWidget={updateWidget}
>
{component}
</Widget>
);
};
+31
View File
@@ -0,0 +1,31 @@
import { GlobalSettings, SearchWidgetData, ClockWidgetData } from './types';
export const DEFAULT_SETTINGS: GlobalSettings = {
backgroundUrl: '',
widgets: [
{
id: 'clock-1',
type: 'clock',
gridX: 15,
gridY: 7,
gridWidth: 6, // Clock: 6x3
gridHeight: 3,
dragEnabled: true,
showSeconds: true,
use24Hour: false,
timezone: 'UTC' // Default timezone
} as ClockWidgetData,
{
id: 'search-1',
type: 'search',
gridX: 13,
gridY: 10,
gridWidth: 10, // Search: 10x2
gridHeight: 2,
dragEnabled: true,
engine: 'Google'
} as SearchWidgetData
],
dragEnabled: true,
showGrid: false
};
+6
View File
@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);
+16
View File
@@ -0,0 +1,16 @@
import { GlobalSettings, WidgetData } from '../types';
import { DEFAULT_SETTINGS } from '../defaults';
export const settingsService = {
saveSetting: async <K extends keyof GlobalSettings>(key: K, value: GlobalSettings[K]) => {
return await chrome.storage.sync.set({
[key]: value
})
},
getSetting: async <K extends keyof GlobalSettings>(key: K): Promise<GlobalSettings[K]> => {
const loaded = await chrome.storage.sync.get([key])
return loaded[key] as GlobalSettings[K]
},
};
@@ -0,0 +1,9 @@
import { atomWithObservable, loadable } from "jotai/utils";
import { settingsService } from "../services/settingsService";
import { from } from 'rxjs';
const backgroundImageState = atomWithObservable(() =>
from(settingsService.getSetting('backgroundUrl'))
);
export const loadableBackgroundImageState = loadable(backgroundImageState)
+3
View File
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const draggableState = atom<boolean>(false)
+3
View File
@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const showGridState = atom<boolean>(false)
+9
View File
@@ -0,0 +1,9 @@
import { atomWithObservable, loadable } from "jotai/utils";
import { settingsService } from "../services/settingsService";
import { from } from 'rxjs';
const widgetsState = atomWithObservable(() =>
from(settingsService.getSetting('widgets'))
);
export const loadableWidgetsState = loadable(widgetsState)
+35
View File
@@ -0,0 +1,35 @@
export interface WidgetData {
id: string;
type: WidgetTypes;
gridX: number;
gridY: number;
gridWidth: number;
gridHeight: number;
[key: string]: any;
}
export interface SearchWidgetData extends WidgetData {
type: 'search';
engine: 'Google' | 'Bing' | 'DuckDuckGo' | 'Yandex';
}
export interface ClockWidgetData extends WidgetData {
type: 'clock';
showSeconds: boolean;
use24Hour: boolean;
timezone: string;
}
export type WidgetTypes = (SearchWidgetData | ClockWidgetData)['type']
export interface GlobalSettings {
backgroundUrl: string;
widgets: WidgetData[];
dragEnabled: boolean;
showGrid: boolean;
}
export type WidgetExport<W = React.FC<any>, S = (...a:any[]) =>JSX.Element > = {
widget: W,
settings: S
}