Workspaces in Workspaces all around
This commit is contained in:
@@ -10,10 +10,10 @@ const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
|
||||
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
|
||||
<div className="flex justify-center">
|
||||
<details className="max-w-[85%] rounded-2xl bg-duck-dark/5 border border-duck-dark/10 px-4 py-2 text-sm">
|
||||
<summary className="text-xs text-duck-dark/40 hover:text-duck-dark/60 cursor-pointer select-none">
|
||||
<summary className="text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer select-none">
|
||||
{label}
|
||||
</summary>
|
||||
<div className="mt-1.5 text-xs text-duck-dark/40 whitespace-pre-wrap border-l-2 border-duck-dark/10 pl-2 max-h-48 overflow-y-auto">
|
||||
<div className="mt-1.5 text-xs text-duck-dark/60 whitespace-pre-wrap border-l-2 border-duck-dark/15 pl-2 max-h-48 overflow-y-auto">
|
||||
{content}
|
||||
</div>
|
||||
</details>
|
||||
@@ -53,7 +53,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
if (!message.text) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-background/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{message.text}
|
||||
</ReactMarkdown>
|
||||
@@ -81,7 +81,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
case 'error':
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-700">
|
||||
<div className="max-w-[80%] rounded-2xl bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 px-4 py-2.5 text-sm text-red-700 dark:text-red-300">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-background/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm dark:prose-invert max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -72,7 +72,7 @@ export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) =
|
||||
return (
|
||||
<div className="my-1 space-y-3">
|
||||
{questions.map((q, qi) => (
|
||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-white/90 overflow-hidden">
|
||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-background/90 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
|
||||
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
||||
|
||||
@@ -120,7 +120,7 @@ const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
|
||||
<>
|
||||
<pre
|
||||
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
||||
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 text-red-700' : 'text-duck-dark/70'
|
||||
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 dark:bg-red-950/50 text-red-700 dark:text-red-300' : 'text-duck-dark/70'
|
||||
}`}
|
||||
>
|
||||
{displayText}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const SessionBar = ({
|
||||
onDelete,
|
||||
onToggleFullscreen,
|
||||
}: SessionBarProps) => (
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-background/60">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
|
||||
import { RefreshCw, FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check, Download } from 'lucide-react';
|
||||
|
||||
type ToolbarProps = {
|
||||
onRefresh: () => void;
|
||||
onCreateDir: (name: string) => void;
|
||||
onUpload: (files: FileList) => void;
|
||||
selectionCount: number;
|
||||
@@ -9,11 +10,13 @@ type ToolbarProps = {
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onClearSelection: () => void;
|
||||
};
|
||||
|
||||
export const Toolbar = ({
|
||||
onRefresh,
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
selectionCount,
|
||||
@@ -21,6 +24,7 @@ export const Toolbar = ({
|
||||
onCut,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDownloadSelected,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
}: ToolbarProps) => {
|
||||
@@ -69,9 +73,20 @@ export const Toolbar = ({
|
||||
</>
|
||||
);
|
||||
|
||||
const refreshBtn = (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
title="Refresh"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (selectionCount > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{refreshBtn}
|
||||
<span className="text-sm font-medium text-duck-dark/70 mr-1">
|
||||
{selectionCount}
|
||||
<span className="hidden md:inline"> selected</span>
|
||||
@@ -99,6 +114,13 @@ export const Toolbar = ({
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDownloadSelected}
|
||||
title="Download"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeleteSelected}
|
||||
title="Delete"
|
||||
@@ -120,6 +142,7 @@ export const Toolbar = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{refreshBtn}
|
||||
{showInput ? (
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
@@ -133,7 +156,7 @@ export const Toolbar = ({
|
||||
value={folderName}
|
||||
onChange={(ev) => setFolderName(ev.target.value)}
|
||||
placeholder="Folder name"
|
||||
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setShowInput(false);
|
||||
|
||||
@@ -15,6 +15,16 @@ type ListDirResponse = {
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
const triggerBlobDownload = (blob: Blob, filename: string) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
};
|
||||
|
||||
export const useFiles = (root: string = 'home') => {
|
||||
const client = useClient();
|
||||
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
||||
@@ -58,6 +68,26 @@ export const useFiles = (root: string = 'home') => {
|
||||
|
||||
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
|
||||
|
||||
download: async (paths: string[]) => {
|
||||
if (paths.length === 1) {
|
||||
const blob = await client.getBlob(withRoot(`/file-browser/download?path=${encodeURIComponent(paths[0]!)}`));
|
||||
const name = paths[0]!.split('/').pop()!;
|
||||
const isDir = !name.includes('.') || blob.type === 'application/zip';
|
||||
triggerBlobDownload(blob, isDir && !name.endsWith('.zip') ? `${name}.zip` : name);
|
||||
} else {
|
||||
const headers = getHeaders();
|
||||
const url = `${config.API_URL}/${withRoot('/file-browser/download').replace(/^\//, '')}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Download failed');
|
||||
const blob = await res.blob();
|
||||
triggerBlobDownload(blob, 'download.zip');
|
||||
}
|
||||
},
|
||||
|
||||
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
for (const file of Array.from(files)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ export const ColorPicker = (props: ColorPickerProps) => {
|
||||
const { value = '#000000', name, label } = props;
|
||||
return (
|
||||
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
|
||||
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-white px-2 text-xs">{label}</span>}
|
||||
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-12 overflow-hidden rounded-full p-0">
|
||||
<Input
|
||||
|
||||
@@ -21,7 +21,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
};
|
||||
|
||||
const buttonClass =
|
||||
'h-8 w-8 p-0 hover:bg-white text-muted-foreground disabled:opacity-100 disabled:pointer-events-none';
|
||||
'h-8 w-8 p-0 hover:bg-background text-muted-foreground disabled:opacity-100 disabled:pointer-events-none';
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 flex w-full items-center justify-between border-t p-4">
|
||||
@@ -44,7 +44,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
justify-between
|
||||
bg-transparent
|
||||
text-muted-foreground
|
||||
hover:bg-white
|
||||
hover:bg-background
|
||||
data-[state=open]:bg-muted
|
||||
"
|
||||
aria-label="Rows per page"
|
||||
|
||||
@@ -69,7 +69,7 @@ export function MarkdownEditor({
|
||||
) : (
|
||||
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
|
||||
{/* Email wrapper simulation */}
|
||||
<div className="mx-auto max-w-2xl rounded-lg bg-white dark:bg-slate-800 p-8 shadow-lg">
|
||||
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
|
||||
|
||||
@@ -14,7 +14,7 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-white/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-white/90 transition-colors cursor-pointer"
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<entry.icon className="h-5 w-5" />
|
||||
|
||||
@@ -20,6 +20,8 @@ export type WorkspaceDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
description?: string;
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type AppRegistryEntry = {
|
||||
|
||||
@@ -1,45 +1,40 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type ColorMode = 'light' | 'dark';
|
||||
|
||||
const initialState = {
|
||||
theme: 'light',
|
||||
setTheme: (theme: string) => {
|
||||
theme;
|
||||
return null;
|
||||
},
|
||||
colorMode: 'light' as ColorMode,
|
||||
setColorMode: (_mode: ColorMode) => {},
|
||||
};
|
||||
|
||||
type ThemeProviderProps = { children: React.ReactNode; defaultTheme?: string; storageKey?: string };
|
||||
type ColorModeProviderProps = { children: React.ReactNode; defaultMode?: ColorMode; storageKey?: string };
|
||||
|
||||
const ThemeProviderContext = createContext(initialState);
|
||||
const ColorModeContext = createContext(initialState);
|
||||
|
||||
export function ThemeProvider(props: ThemeProviderProps) {
|
||||
const { children, defaultTheme = 'light', storageKey = 'theme' } = props;
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem(storageKey) || defaultTheme);
|
||||
export function ColorModeProvider(props: ColorModeProviderProps) {
|
||||
const { children, defaultMode = 'light', storageKey = 'officer-color-mode' } = props;
|
||||
const [colorMode, setColorModeState] = useState<ColorMode>(
|
||||
() => (localStorage.getItem(storageKey) as ColorMode) || defaultMode,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(colorMode);
|
||||
}, [colorMode]);
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (newTheme: string) => {
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setTheme(newTheme);
|
||||
return null;
|
||||
},
|
||||
const setColorMode = (mode: ColorMode) => {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
setColorModeState(mode);
|
||||
};
|
||||
|
||||
return <ThemeProviderContext.Provider value={value}>{children}</ThemeProviderContext.Provider>;
|
||||
return <ColorModeContext.Provider value={{ colorMode, setColorMode }}>{children}</ColorModeContext.Provider>;
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
export const useColorMode = () => {
|
||||
const context = useContext(ColorModeContext);
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
if (context === undefined) throw new Error('useColorMode must be used within a ColorModeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import { useColorMode } from './ThemeProvider';
|
||||
|
||||
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
|
||||
|
||||
export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||
const { colorDark, colorLight } = props;
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
|
||||
const changeTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
const toggle = () => {
|
||||
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cursor-pointer" onClick={changeTheme}>
|
||||
<div className="cursor-pointer" onClick={toggle}>
|
||||
<Sun
|
||||
className={cn(
|
||||
'h-[1.2rem] w-[1.2rem] origin-center rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0',
|
||||
@@ -26,7 +26,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||
colorDark && 'text-black',
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
<span className="sr-only">Toggle color mode</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/components/ui/ThemeProvider';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
import { cn } from 'helpers/cn';
|
||||
|
||||
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
onClick={() => setColorMode(colorMode === 'light' ? 'dark' : 'light')}
|
||||
className={cn('h-9 w-9', className)}
|
||||
{...props}
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
<span className="sr-only">Toggle color mode</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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 { Widget } from '../Widget';
|
||||
|
||||
type Goal = {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
type DailyGoalsState = {
|
||||
goals: Goal[];
|
||||
lastDate: string;
|
||||
streak: number;
|
||||
lastCompletedDate: string | null;
|
||||
};
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
const GOALS_STATE_KEY = 'daily-goals';
|
||||
|
||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||
const yesterdayStr = () => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
function useDailyGoalsState() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const stored = userState[GOALS_STATE_KEY] as DailyGoalsState | undefined;
|
||||
const today = todayStr();
|
||||
|
||||
const state: DailyGoalsState = (() => {
|
||||
if (!stored) return { goals: [], lastDate: today, streak: 0, lastCompletedDate: null };
|
||||
if (stored.lastDate === today) return stored;
|
||||
|
||||
// New day — check if yesterday had all goals completed for streak
|
||||
const allDoneYesterday = stored.goals.length > 0 && stored.goals.every((g) => g.done);
|
||||
const wasYesterday = stored.lastDate === yesterdayStr();
|
||||
const streak = allDoneYesterday && wasYesterday ? stored.streak + 1 : allDoneYesterday ? 1 : 0;
|
||||
const lastCompletedDate = allDoneYesterday ? stored.lastDate : stored.lastCompletedDate;
|
||||
|
||||
return {
|
||||
goals: stored.goals.map((g) => ({ ...g, done: false })),
|
||||
lastDate: today,
|
||||
streak,
|
||||
lastCompletedDate,
|
||||
};
|
||||
})();
|
||||
|
||||
const setState = useCallback(
|
||||
(update: DailyGoalsState | ((prev: DailyGoalsState) => DailyGoalsState)) => {
|
||||
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
const current = (currentUserState[GOALS_STATE_KEY] as DailyGoalsState | undefined) ?? state;
|
||||
const next = typeof update === 'function' ? update(current) : update;
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [GOALS_STATE_KEY]: next });
|
||||
clientRef.current.patch('/user/state', { [GOALS_STATE_KEY]: next }).catch(() => {});
|
||||
},
|
||||
[queryClient, state],
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
|
||||
let nextGoalId = Date.now();
|
||||
|
||||
export const DailyGoals = () => {
|
||||
const [state, setState] = useDailyGoalsState();
|
||||
const [newGoal, setNewGoal] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addGoal = () => {
|
||||
const text = newGoal.trim();
|
||||
if (!text) return;
|
||||
nextGoalId++;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
goals: [...s.goals, { id: String(nextGoalId), text, done: false }],
|
||||
}));
|
||||
setNewGoal('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const toggleGoal = (id: string) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
goals: s.goals.map((g) => (g.id === id ? { ...g, done: !g.done } : g)),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeGoal = (id: string) => {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
goals: s.goals.filter((g) => g.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
const { goals, streak } = state;
|
||||
const doneCount = goals.filter((g) => g.done).length;
|
||||
|
||||
return (
|
||||
<Widget title="Daily Goals">
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-warning">
|
||||
<Flame size={12} />
|
||||
<span>{streak} day streak</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{goals.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{goals.map((goal) => (
|
||||
<div key={goal.id} className="group flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border cursor-pointer transition-colors ${
|
||||
goal.done ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary'
|
||||
}`}
|
||||
onClick={() => toggleGoal(goal.id)}
|
||||
>
|
||||
{goal.done && (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M2 5l2.5 2.5L8 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span className={`flex-1 text-sm ${goal.done ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{goal.text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded cursor-pointer text-muted-foreground opacity-0 hover:text-destructive group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => removeGoal(goal.id)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{goals.length > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{doneCount}/{goals.length} completed
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Add a goal..."
|
||||
value={newGoal}
|
||||
onChange={(ev) => setNewGoal(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
addGoal();
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={addGoal}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
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 { allSounds, categories } from 'sounds';
|
||||
import type { SoundAsset } from 'sounds';
|
||||
import { Widget } from '../Widget';
|
||||
|
||||
type PomodoroSettings = {
|
||||
focusMinutes: number;
|
||||
breakMinutes: number;
|
||||
longBreakMinutes: number;
|
||||
sessionsBeforeLong: number;
|
||||
focusEndSound: string | null;
|
||||
breakEndSound: string | null;
|
||||
};
|
||||
|
||||
type PomodoroState = {
|
||||
settings: PomodoroSettings;
|
||||
completedToday: number;
|
||||
lastDate: string;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: PomodoroSettings = {
|
||||
focusMinutes: 25,
|
||||
breakMinutes: 5,
|
||||
longBreakMinutes: 15,
|
||||
sessionsBeforeLong: 4,
|
||||
focusEndSound: 'confirmation-002',
|
||||
breakEndSound: 'notification-pop',
|
||||
};
|
||||
|
||||
const playSound = (name: string | null) => {
|
||||
if (!name) return;
|
||||
const sound = allSounds.find((s) => s.name === name);
|
||||
if (sound) new Audio(sound.dataUri).play();
|
||||
};
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
const POMODORO_STATE_KEY = 'pomodoro-state';
|
||||
|
||||
const todayStr = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function usePomodoroState() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const stored = userState[POMODORO_STATE_KEY] as PomodoroState | undefined;
|
||||
const state: PomodoroState = stored
|
||||
? { ...stored, completedToday: stored.lastDate === todayStr() ? stored.completedToday : 0, lastDate: todayStr() }
|
||||
: { settings: DEFAULT_SETTINGS, completedToday: 0, lastDate: todayStr() };
|
||||
|
||||
const setState = useCallback(
|
||||
(update: PomodoroState | ((prev: PomodoroState) => PomodoroState)) => {
|
||||
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
const current = (currentUserState[POMODORO_STATE_KEY] as PomodoroState | undefined) ?? state;
|
||||
const next = typeof update === 'function' ? update(current) : update;
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [POMODORO_STATE_KEY]: next });
|
||||
clientRef.current.patch('/user/state', { [POMODORO_STATE_KEY]: next }).catch(() => {});
|
||||
},
|
||||
[queryClient, state],
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
|
||||
type Phase = 'focus' | 'break' | 'long-break';
|
||||
|
||||
const phaseLabel: Record<Phase, string> = {
|
||||
'focus': 'Focus',
|
||||
'break': 'Break',
|
||||
'long-break': 'Long Break',
|
||||
};
|
||||
|
||||
const phaseColor: Record<Phase, string> = {
|
||||
'focus': 'text-destructive',
|
||||
'break': 'text-success',
|
||||
'long-break': 'text-accent',
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatSoundName = (name: string) =>
|
||||
name
|
||||
.split('-')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
|
||||
type SoundPickerProps = {
|
||||
label: string;
|
||||
value: string | null;
|
||||
onChange: (name: string | null) => void;
|
||||
};
|
||||
|
||||
const SoundPicker = ({ label, value, onChange }: SoundPickerProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [previewName, setPreviewName] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const source = activeCategory === 'all' ? allSounds : (categories.find((c) => c.id === activeCategory)?.sounds ?? []);
|
||||
if (!search) return source;
|
||||
const q = search.toLowerCase();
|
||||
return source.filter((s) => s.name.toLowerCase().includes(q));
|
||||
}, [search, activeCategory]);
|
||||
|
||||
const preview = (sound: SoundAsset) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
const audio = new Audio(sound.dataUri);
|
||||
audio.addEventListener('ended', () => setPreviewName(null));
|
||||
audio.play();
|
||||
audioRef.current = audio;
|
||||
setPreviewName(sound.name);
|
||||
};
|
||||
|
||||
const stopPreview = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPreviewName(null);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Volume2 size={10} />
|
||||
{value ? formatSoundName(value) : 'None'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-border p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => { stopPreview(); setOpen(false); }}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sounds..."
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[{ id: 'all', label: 'All' }, ...categories.map((c) => ({ id: c.id, label: c.label }))].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(tab.id)}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] cursor-pointer transition-colors border ${
|
||||
activeCategory === tab.id
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-foreground/30'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 rounded px-2 py-1 text-left text-xs cursor-pointer transition-colors ${
|
||||
value === null ? 'bg-primary/10 text-primary' : 'hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => { stopPreview(); onChange(null); }}
|
||||
>
|
||||
None (silent)
|
||||
</button>
|
||||
{filtered.map((s) => (
|
||||
<div key={s.name} className={`flex items-center gap-1 rounded px-2 py-1 text-xs ${value === s.name ? 'bg-primary/10 text-primary' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => previewName === s.name ? stopPreview() : preview(s)}
|
||||
>
|
||||
{previewName === s.name ? <Square size={10} /> : <Play size={10} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left cursor-pointer hover:text-foreground transition-colors truncate"
|
||||
onClick={() => { onChange(s.name); }}
|
||||
>
|
||||
{formatSoundName(s.name)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPanel = ({ settings, onSave }: { settings: PomodoroSettings; onSave: (s: PomodoroSettings) => void }) => {
|
||||
const [draft, setDraft] = useState(settings);
|
||||
|
||||
const numericFields: { key: 'focusMinutes' | 'breakMinutes' | 'longBreakMinutes' | 'sessionsBeforeLong'; label: string }[] = [
|
||||
{ key: 'focusMinutes', label: 'Focus (min)' },
|
||||
{ key: 'breakMinutes', label: 'Break (min)' },
|
||||
{ key: 'longBreakMinutes', label: 'Long break (min)' },
|
||||
{ key: 'sessionsBeforeLong', label: 'Sessions before long' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{numericFields.map(({ key, label }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={draft[key]}
|
||||
onChange={(ev) => setDraft((d) => ({ ...d, [key]: Math.max(1, Number(ev.target.value)) }))}
|
||||
className="w-16 rounded border border-border bg-background px-2 py-1 text-right text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<SoundPicker
|
||||
label="Focus end sound"
|
||||
value={draft.focusEndSound}
|
||||
onChange={(name) => setDraft((d) => ({ ...d, focusEndSound: name }))}
|
||||
/>
|
||||
<SoundPicker
|
||||
label="Break end sound"
|
||||
value={draft.breakEndSound}
|
||||
onChange={(name) => setDraft((d) => ({ ...d, breakEndSound: name }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 flex items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => onSave(draft)}
|
||||
>
|
||||
<Check size={12} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Pomodoro = () => {
|
||||
const [pomState, setPomState] = usePomodoroState();
|
||||
const { settings, completedToday } = pomState;
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('focus');
|
||||
const [running, setRunning] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [sessionCount, setSessionCount] = useState(0);
|
||||
|
||||
const phaseDuration = (p: Phase) => {
|
||||
if (p === 'focus') return settings.focusMinutes * 60;
|
||||
if (p === 'break') return settings.breakMinutes * 60;
|
||||
return settings.longBreakMinutes * 60;
|
||||
};
|
||||
|
||||
const [remaining, setRemaining] = useState(phaseDuration('focus'));
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((r) => {
|
||||
if (r <= 1) {
|
||||
clearTimer();
|
||||
setRunning(false);
|
||||
if (phase === 'focus') {
|
||||
playSound(settings.focusEndSound);
|
||||
const newCount = sessionCount + 1;
|
||||
setSessionCount(newCount);
|
||||
setPomState((s) => ({ ...s, completedToday: s.completedToday + 1, lastDate: todayStr() }));
|
||||
const isLongBreak = newCount % settings.sessionsBeforeLong === 0;
|
||||
const nextPhase: Phase = isLongBreak ? 'long-break' : 'break';
|
||||
setPhase(nextPhase);
|
||||
setRemaining(phaseDuration(nextPhase));
|
||||
} else {
|
||||
playSound(settings.breakEndSound);
|
||||
setPhase('focus');
|
||||
setRemaining(phaseDuration('focus'));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return r - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return clearTimer;
|
||||
}, [running, phase, sessionCount, settings]);
|
||||
|
||||
const reset = () => {
|
||||
clearTimer();
|
||||
setRunning(false);
|
||||
setPhase('focus');
|
||||
setRemaining(phaseDuration('focus'));
|
||||
};
|
||||
|
||||
if (showSettings) {
|
||||
return (
|
||||
<Widget title="Pomodoro">
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
onSave={(s) => {
|
||||
setPomState((prev) => ({ ...prev, settings: s }));
|
||||
setShowSettings(false);
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = 1 - remaining / phaseDuration(phase);
|
||||
const circumference = 2 * Math.PI * 40;
|
||||
const dashOffset = circumference * (1 - progress);
|
||||
|
||||
return (
|
||||
<Widget title="Pomodoro">
|
||||
<div className="flex flex-col items-center gap-3 px-4 pb-4">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<svg width="100" height="100" className="-rotate-90">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="currentColor" strokeWidth="4" className="text-muted/30" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
className={phaseColor[phase]}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-xl font-bold tabular-nums">{formatTime(remaining)}</span>
|
||||
</div>
|
||||
|
||||
<span className={`text-xs font-medium ${phaseColor[phase]}`}>{phaseLabel[phase]}</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => setRunning((r) => !r)}
|
||||
>
|
||||
{running ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={reset}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-border hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => setShowSettings(true)}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{completedToday} session{completedToday !== 1 ? 's' : ''} today</span>
|
||||
</div>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
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 { Widget } from '../Widget';
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
const NOTES_STATE_KEY = 'quick-notes';
|
||||
|
||||
function useQuickNotes() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: userState = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const content = (userState[NOTES_STATE_KEY] as string | undefined) ?? '';
|
||||
|
||||
const setContent = useCallback(
|
||||
(text: string) => {
|
||||
const currentUserState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentUserState, [NOTES_STATE_KEY]: text });
|
||||
clientRef.current.patch('/user/state', { [NOTES_STATE_KEY]: text }).catch(() => {});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return [content, setContent] as const;
|
||||
}
|
||||
|
||||
export const QuickNotes = () => {
|
||||
const [content, setContent] = useQuickNotes();
|
||||
const [local, setLocal] = useState(content);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Sync from server on first meaningful load
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
if (content) {
|
||||
setLocal(content);
|
||||
initialized.current = true;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setLocal(text);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setContent(text), 500);
|
||||
};
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Widget title="Quick Notes">
|
||||
<div className="px-4 pb-4">
|
||||
<textarea
|
||||
value={local}
|
||||
onChange={(ev) => handleChange(ev.target.value)}
|
||||
placeholder="Jot something down..."
|
||||
className="h-32 w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
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 { Widget } from '../Widget';
|
||||
import { getWeatherInfo } from './weather-codes';
|
||||
|
||||
type WeatherLocation = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type GeocodingResult = {
|
||||
id: number;
|
||||
name: string;
|
||||
country: string;
|
||||
admin1?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
type CurrentWeather = {
|
||||
temperature_2m: number;
|
||||
apparent_temperature: number;
|
||||
weather_code: number;
|
||||
wind_speed_10m: number;
|
||||
relative_humidity_2m: number;
|
||||
is_day: number;
|
||||
};
|
||||
|
||||
type DailyWeather = {
|
||||
time: string[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
weather_code: number[];
|
||||
};
|
||||
|
||||
type ForecastResponse = {
|
||||
current: CurrentWeather;
|
||||
daily: DailyWeather;
|
||||
};
|
||||
|
||||
const USER_STATE_KEY = ['USER_STATE'];
|
||||
const LOCATION_STATE_KEY = 'weather-location';
|
||||
|
||||
function useWeatherLocation() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {} } = useQuery<Record<string, unknown>>({
|
||||
queryKey: USER_STATE_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get('/user/state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const location = (state[LOCATION_STATE_KEY] as WeatherLocation | undefined) ?? null;
|
||||
|
||||
const setLocation = useCallback(
|
||||
(loc: WeatherLocation | null) => {
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(USER_STATE_KEY) ?? {};
|
||||
queryClient.setQueryData(USER_STATE_KEY, { ...currentState, [LOCATION_STATE_KEY]: loc });
|
||||
clientRef.current.patch('/user/state', { [LOCATION_STATE_KEY]: loc }).catch(() => {});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
return [location, setLocation] as const;
|
||||
}
|
||||
|
||||
const LocationSetup = ({ onSelect }: { onSelect: (loc: WeatherLocation) => void }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [geoLoading, setGeoLoading] = useState(false);
|
||||
|
||||
const { data: results = [] } = useQuery<GeocodingResult[]>({
|
||||
queryKey: ['geocoding', query],
|
||||
enabled: query.length >= 2,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5`);
|
||||
const data = await res.json();
|
||||
return data.results ?? [];
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const useGeolocation = () => {
|
||||
if (!navigator.geolocation) return;
|
||||
setGeoLoading(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
const { latitude, longitude } = pos.coords;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://geocoding-api.open-meteo.com/v1/search?name=${latitude.toFixed(2)},${longitude.toFixed(2)}&count=1`,
|
||||
);
|
||||
const data = await res.json();
|
||||
const name = data.results?.[0]?.name ?? `${latitude.toFixed(2)}, ${longitude.toFixed(2)}`;
|
||||
onSelect({ latitude, longitude, name });
|
||||
} catch {
|
||||
onSelect({ latitude, longitude, name: `${latitude.toFixed(2)}, ${longitude.toFixed(2)}` });
|
||||
} finally {
|
||||
setGeoLoading(false);
|
||||
}
|
||||
},
|
||||
() => setGeoLoading(false),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm hover:bg-accent transition-colors cursor-pointer disabled:opacity-50"
|
||||
onClick={useGeolocation}
|
||||
disabled={geoLoading}
|
||||
>
|
||||
<MapPin size={14} />
|
||||
{geoLoading ? 'Locating...' : 'Use my location'}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search city..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
|
||||
{results.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent transition-colors cursor-pointer"
|
||||
onClick={() => onSelect({ latitude: r.latitude, longitude: r.longitude, name: r.name })}
|
||||
>
|
||||
<MapPin size={12} className="shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
{r.name}
|
||||
{r.admin1 ? `, ${r.admin1}` : ''}, {r.country}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dayName = (dateStr: string) => {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short' });
|
||||
};
|
||||
|
||||
const WeatherDisplay = ({ location, onReset }: { location: WeatherLocation; onReset: () => void }) => {
|
||||
const { data, isLoading } = useQuery<ForecastResponse>({
|
||||
queryKey: ['weather', location.latitude, location.longitude],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
latitude: String(location.latitude),
|
||||
longitude: String(location.longitude),
|
||||
current: 'temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m,is_day',
|
||||
daily: 'temperature_2m_max,temperature_2m_min,weather_code',
|
||||
timezone: 'auto',
|
||||
forecast_days: '5',
|
||||
});
|
||||
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 15 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<div className="h-16 animate-pulse rounded bg-muted" />
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-16 flex-1 animate-pulse rounded bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { current, daily } = data;
|
||||
const info = getWeatherInfo(current.weather_code, current.is_day === 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{info.icon}</span>
|
||||
<div>
|
||||
<div className="text-2xl font-bold tabular-nums">{Math.round(current.temperature_2m)}°C</div>
|
||||
<div className="text-xs text-muted-foreground">{info.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={onReset}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>Feels {Math.round(current.apparent_temperature)}°C</span>
|
||||
<span>💧 {current.relative_humidity_2m}%</span>
|
||||
<span>💨 {Math.round(current.wind_speed_10m)} km/h</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin size={10} />
|
||||
<span>{location.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
{daily.time.map((date, i) => {
|
||||
const dayInfo = getWeatherInfo(daily.weather_code[i]!, true);
|
||||
return (
|
||||
<div key={date} className="flex flex-1 flex-col items-center gap-0.5 rounded-md bg-muted/50 py-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">{dayName(date)}</span>
|
||||
<span className="text-sm">{dayInfo.icon}</span>
|
||||
<span className="text-[10px] tabular-nums">
|
||||
{Math.round(daily.temperature_2m_max[i]!)}° / {Math.round(daily.temperature_2m_min[i]!)}°
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Weather = () => {
|
||||
const [location, setLocation] = useWeatherLocation();
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
|
||||
if (!location || showSetup) {
|
||||
return (
|
||||
<Widget title="Weather">
|
||||
<LocationSetup
|
||||
onSelect={(loc) => {
|
||||
setLocation(loc);
|
||||
setShowSetup(false);
|
||||
}}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget title="Weather">
|
||||
<WeatherDisplay
|
||||
location={location}
|
||||
onReset={() => setShowSetup(true)}
|
||||
/>
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
type WeatherInfo = { icon: string; label: string };
|
||||
|
||||
const codes: Record<number, { day: WeatherInfo; night: WeatherInfo }> = {
|
||||
0: { day: { icon: '☀️', label: 'Clear sky' }, night: { icon: '🌙', label: 'Clear sky' } },
|
||||
1: { day: { icon: '🌤️', label: 'Mainly clear' }, night: { icon: '🌙', label: 'Mainly clear' } },
|
||||
2: { day: { icon: '⛅', label: 'Partly cloudy' }, night: { icon: '☁️', label: 'Partly cloudy' } },
|
||||
3: { day: { icon: '☁️', label: 'Overcast' }, night: { icon: '☁️', label: 'Overcast' } },
|
||||
45: { day: { icon: '🌫️', label: 'Fog' }, night: { icon: '🌫️', label: 'Fog' } },
|
||||
48: { day: { icon: '🌫️', label: 'Rime fog' }, night: { icon: '🌫️', label: 'Rime fog' } },
|
||||
51: { day: { icon: '🌦️', label: 'Light drizzle' }, night: { icon: '🌧️', label: 'Light drizzle' } },
|
||||
53: { day: { icon: '🌦️', label: 'Drizzle' }, night: { icon: '🌧️', label: 'Drizzle' } },
|
||||
55: { day: { icon: '🌧️', label: 'Heavy drizzle' }, night: { icon: '🌧️', label: 'Heavy drizzle' } },
|
||||
56: { day: { icon: '🌧️', label: 'Freezing drizzle' }, night: { icon: '🌧️', label: 'Freezing drizzle' } },
|
||||
57: { day: { icon: '🌧️', label: 'Heavy freezing drizzle' }, night: { icon: '🌧️', label: 'Heavy freezing drizzle' } },
|
||||
61: { day: { icon: '🌦️', label: 'Light rain' }, night: { icon: '🌧️', label: 'Light rain' } },
|
||||
63: { day: { icon: '🌧️', label: 'Rain' }, night: { icon: '🌧️', label: 'Rain' } },
|
||||
65: { day: { icon: '🌧️', label: 'Heavy rain' }, night: { icon: '🌧️', label: 'Heavy rain' } },
|
||||
66: { day: { icon: '🌧️', label: 'Freezing rain' }, night: { icon: '🌧️', label: 'Freezing rain' } },
|
||||
67: { day: { icon: '🌧️', label: 'Heavy freezing rain' }, night: { icon: '🌧️', label: 'Heavy freezing rain' } },
|
||||
71: { day: { icon: '🌨️', label: 'Light snow' }, night: { icon: '🌨️', label: 'Light snow' } },
|
||||
73: { day: { icon: '❄️', label: 'Snow' }, night: { icon: '❄️', label: 'Snow' } },
|
||||
75: { day: { icon: '❄️', label: 'Heavy snow' }, night: { icon: '❄️', label: 'Heavy snow' } },
|
||||
77: { day: { icon: '🌨️', label: 'Snow grains' }, night: { icon: '🌨️', label: 'Snow grains' } },
|
||||
80: { day: { icon: '🌦️', label: 'Light showers' }, night: { icon: '🌧️', label: 'Light showers' } },
|
||||
81: { day: { icon: '🌧️', label: 'Showers' }, night: { icon: '🌧️', label: 'Showers' } },
|
||||
82: { day: { icon: '🌧️', label: 'Heavy showers' }, night: { icon: '🌧️', label: 'Heavy showers' } },
|
||||
85: { day: { icon: '🌨️', label: 'Light snow showers' }, night: { icon: '🌨️', label: 'Light snow showers' } },
|
||||
86: { day: { icon: '❄️', label: 'Heavy snow showers' }, night: { icon: '❄️', label: 'Heavy snow showers' } },
|
||||
95: { day: { icon: '⛈️', label: 'Thunderstorm' }, night: { icon: '⛈️', label: 'Thunderstorm' } },
|
||||
96: { day: { icon: '⛈️', label: 'Thunderstorm with hail' }, night: { icon: '⛈️', label: 'Thunderstorm with hail' } },
|
||||
99: { day: { icon: '⛈️', label: 'Thunderstorm with heavy hail' }, night: { icon: '⛈️', label: 'Thunderstorm with heavy hail' } },
|
||||
};
|
||||
|
||||
export const getWeatherInfo = (code: number, isDay: boolean): WeatherInfo => {
|
||||
const entry = codes[code];
|
||||
if (!entry) return { icon: '🌡️', label: 'Unknown' };
|
||||
return isDay ? entry.day : entry.night;
|
||||
};
|
||||
@@ -4,6 +4,10 @@
|
||||
"exports": {
|
||||
"./Widget": "./Widget.tsx",
|
||||
"./Clock": "./Clock/index.tsx",
|
||||
"./Weather": "./Weather/index.tsx",
|
||||
"./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,7 +1,15 @@
|
||||
import { Clock as ClockIcon } from 'lucide-react';
|
||||
import { Clock as ClockIcon, CloudSun, Timer, Target, StickyNote } from 'lucide-react';
|
||||
import type { AppRegistryEntry } from '@/components/Workspace';
|
||||
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 /> },
|
||||
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather /> },
|
||||
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro /> },
|
||||
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals /> },
|
||||
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes /> },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user