This commit is contained in:
2026-02-23 05:49:33 +00:00
parent a601b8e078
commit 46c3b6b71d
17 changed files with 98 additions and 27 deletions
+6
View File
@@ -1,4 +1,6 @@
import type { WidgetRegistryMeta } from 'officerdev';
import { useState, useEffect } from 'react';
import { Clock as ClockIcon } from 'lucide-react';
import { Widget } from '../Widget';
export const Clock = () => {
@@ -21,3 +23,7 @@ export const Clock = () => {
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'clock', name: 'Clock', icon: ClockIcon, component: Clock },
];
+6 -1
View File
@@ -1,8 +1,9 @@
import type { WidgetRegistryMeta } from 'officerdev';
import { useCallback, useRef, useState } from 'react';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { Plus, Trash2, Flame } from 'lucide-react';
import { Plus, Target, Trash2, Flame } from 'lucide-react';
import { Widget } from '../Widget';
type Goal = {
@@ -188,3 +189,7 @@ export const DailyGoals = () => {
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'daily-goals', name: 'Daily Goals', icon: Target, component: DailyGoals },
];
+6 -1
View File
@@ -1,8 +1,9 @@
import type { WidgetRegistryMeta } from 'officerdev';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { Pause, Play, RotateCcw, Settings, Check, Volume2, Square } from 'lucide-react';
import { Pause, Play, RotateCcw, Settings, Check, Timer, Volume2, Square } from 'lucide-react';
import { allSounds, categories } from 'sounds';
import type { SoundAsset } from 'sounds';
import { Widget } from '../Widget';
@@ -412,3 +413,7 @@ export const Pomodoro = () => {
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'pomodoro', name: 'Pomodoro', icon: Timer, component: Pomodoro },
];
@@ -1,7 +1,9 @@
import type { WidgetRegistryMeta } from 'officerdev';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient, useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { StickyNote } from 'lucide-react';
import { Widget } from '../Widget';
const USER_STATE_KEY = ['USER_STATE'];
@@ -76,3 +78,7 @@ export const QuickNotes = () => {
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'quick-notes', name: 'Quick Notes', icon: StickyNote, component: QuickNotes },
];
+6 -1
View File
@@ -1,8 +1,9 @@
import type { WidgetRegistryMeta } from 'officerdev';
import { useCallback, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { MapPin, Settings } from 'lucide-react';
import { CloudSun, MapPin, Settings } from 'lucide-react';
import { Widget } from '../Widget';
import { getWeatherInfo } from './weather-codes';
@@ -266,3 +267,7 @@ export const Weather = () => {
</Widget>
);
};
export const widgetRegistryMetas: WidgetRegistryMeta[] = [
{ key: 'weather', name: 'Weather', icon: CloudSun, component: Weather },
];
@@ -1,25 +0,0 @@
import { widgetRegistry } from '../widget-registry';
type WidgetPickerProps = {
onSelect: (widgetType: string) => void;
};
export const WidgetPicker = ({ onSelect }: WidgetPickerProps) => {
const entries = Object.entries(widgetRegistry);
return (
<div className="grid grid-cols-3 gap-2 max-w-xs">
{entries.map(([key, entry]) => (
<button
key={key}
type="button"
className="flex flex-col items-center gap-1.5 rounded-lg border border-border/50 bg-card/80 backdrop-blur-sm px-3 py-3 text-foreground hover:border-border hover:bg-card transition-colors cursor-pointer"
onClick={() => onSelect(key)}
>
<entry.icon className="h-5 w-5" />
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
</button>
))}
</div>
);
};
@@ -1,238 +0,0 @@
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { Plus, X } from 'lucide-react';
import { widgetRegistry } from '../widget-registry';
import { WidgetPicker } from './WidgetPicker';
type Position = { x: number; y: number }; // percentages (0100)
type WidgetInstance = {
id: string;
widgetType: string;
position: Position;
};
type WidgetPanelConfig = {
instances: WidgetInstance[];
nextId: number;
};
const USER_STATE_KEY = ['USER_STATE'];
function useWidgetPanelState(panelId: string) {
const client = useClient();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const clientRef = useRef(client);
clientRef.current = client;
const stateKey = `widget-panel:${panelId}`;
const { data: state = {} } = useQuery<Record<string, unknown>>({
queryKey: USER_STATE_KEY,
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const config = (state[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
const setConfig = useCallback(
(update: WidgetPanelConfig | ((prev: WidgetPanelConfig) => WidgetPanelConfig)) => {
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
const current = (currentState[stateKey] as WidgetPanelConfig | undefined) ?? { instances: [], nextId: 0 };
const next = typeof update === 'function' ? update(current) : update;
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [stateKey]: next });
clientRef.current.patch('/user/state', { [stateKey]: next }).catch(() => {});
},
[stateKey, queryClient],
);
return [config, setConfig] as const;
}
// --- DraggableWidget ---
type DraggableWidgetProps = {
instance: WidgetInstance;
panelRef: React.RefObject<HTMLDivElement | null>;
onMove: (pos: Position) => void;
onRemove: () => void;
};
type DragState = {
startX: number;
startY: number;
originX: number;
originY: number;
widgetWPct: number;
widgetHPct: number;
panelW: number;
panelH: number;
};
const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidgetProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const onPointerDown = useCallback(
(ev: ReactPointerEvent) => {
const target = ev.target as HTMLElement;
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
const widgetRect = containerRef.current?.getBoundingClientRect();
const panelRect = panelRef.current?.getBoundingClientRect();
if (!widgetRect || !panelRect) return;
dragRef.current = {
startX: ev.clientX,
startY: ev.clientY,
originX: instance.position.x,
originY: instance.position.y,
widgetWPct: (widgetRect.width / panelRect.width) * 100,
widgetHPct: (widgetRect.height / panelRect.height) * 100,
panelW: panelRect.width,
panelH: panelRect.height,
};
containerRef.current?.setPointerCapture(ev.pointerId);
},
[instance.position, panelRef],
);
const endDrag = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
},
[],
);
const onPointerMove = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
const panelRect = panelRef.current?.getBoundingClientRect();
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
endDrag(ev);
return;
}
const d = dragRef.current;
const deltaXPct = ((ev.clientX - d.startX) / d.panelW) * 100;
const deltaYPct = ((ev.clientY - d.startY) / d.panelH) * 100;
onMove({
x: Math.max(0, Math.min(d.originX + deltaXPct, 100 - d.widgetWPct)),
y: Math.max(0, Math.min(d.originY + deltaYPct, 100 - d.widgetHPct)),
});
},
[onMove, panelRef, endDrag],
);
const onPointerUp = useCallback(
(ev: ReactPointerEvent) => {
endDrag(ev);
},
[endDrag],
);
const entry = widgetRegistry[instance.widgetType];
if (!entry) return null;
const WidgetComponent = entry.component;
return (
<div
ref={containerRef}
className="absolute group"
style={{ left: `${instance.position.x}%`, top: `${instance.position.y}%` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<button
type="button"
className="absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 shadow transition-opacity cursor-pointer group-hover:opacity-100"
onClick={onRemove}
>
<X size={12} />
</button>
<WidgetComponent panelId={instance.id} />
</div>
);
};
// --- WidgetPanel ---
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
const [config, setConfig] = useWidgetPanelState(panelId);
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
const [showPicker, setShowPicker] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const nextId = useRef(config.nextId);
const initialized = useRef(false);
// Sync from persisted state on first load
useEffect(() => {
if (initialized.current) return;
if (config.instances.length > 0 || config.nextId > 0) {
setInstances(config.instances);
nextId.current = config.nextId;
initialized.current = true;
}
}, [config]);
// Persist whenever instances change (skip the initial mount)
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
setConfig({ instances, nextId: nextId.current });
}, [instances, setConfig]);
const addWidget = useCallback((widgetType: string) => {
nextId.current++;
const id = `widget-${nextId.current}`;
const offset = (nextId.current % 5) * 5;
setInstances((prev) => [...prev, { id, widgetType, position: { x: 2 + offset, y: 2 + offset } }]);
setShowPicker(false);
}, []);
const removeWidget = useCallback((id: string) => {
setInstances((prev) => prev.filter((w) => w.id !== id));
}, []);
const updatePosition = useCallback((id: string, pos: Position) => {
setInstances((prev) => prev.map((w) => (w.id === id ? { ...w, position: pos } : w)));
}, []);
return (
<div ref={panelRef} className="relative h-full w-full overflow-hidden">
{instances.map((instance) => (
<DraggableWidget
key={instance.id}
instance={instance}
panelRef={panelRef}
onMove={(pos) => updatePosition(instance.id, pos)}
onRemove={() => removeWidget(instance.id)}
/>
))}
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
{showPicker && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />
</div>
)}
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors cursor-pointer"
onClick={() => setShowPicker((v) => !v)}
>
<Plus size={20} />
</button>
</div>
</div>
);
};
-2
View File
@@ -8,8 +8,6 @@
"./Pomodoro": "./Pomodoro/index.tsx",
"./DailyGoals": "./DailyGoals/index.tsx",
"./QuickNotes": "./QuickNotes/index.tsx",
"./widget-registry": "./widget-registry.tsx",
"./WidgetPanel": "./WidgetPanel/index.tsx",
"./Workspaces": "./Workspaces/index.tsx"
}
}
@@ -1,15 +0,0 @@
import { Clock as ClockIcon, CloudSun, Timer, Target, StickyNote } from 'lucide-react';
import type { AppRegistryEntry } from 'officerdev';
import { Clock } from './Clock/index';
import { Weather } from './Weather/index';
import { Pomodoro } from './Pomodoro/index';
import { DailyGoals } from './DailyGoals/index';
import { QuickNotes } from './QuickNotes/index';
export const widgetRegistry: Record<string, AppRegistryEntry> = {
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock />, availableOnPanel: false },
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather />, availableOnPanel: false },
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro />, availableOnPanel: false },
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals />, availableOnPanel: false },
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes />, availableOnPanel: false },
};