Compare commits
16 Commits
c21d7592ae
...
3dee52b6ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee52b6ad | |||
| 2770a9e88e | |||
| 6e0a848c45 | |||
| b663455c26 | |||
| 3afa894e1b | |||
| 246b54d10c | |||
| b2e68d3100 | |||
| 39a42d08ec | |||
| 91f929c39b | |||
| 8d854b703e | |||
| 01c276fc8d | |||
| 62949c997f | |||
| 34ea31421f | |||
| a4b3a8f7fe | |||
| 11fe3df513 | |||
| 6b02cfa1f8 |
@ -495,7 +495,7 @@ export default function CalendarPage() {
|
||||
|
||||
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Custom toolbar */}
|
||||
<div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
|
||||
<div className="border-b bg-card/95 backdrop-blur-md px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setMobileSidebarOpen(true)}>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -511,15 +511,17 @@ export default function CalendarPage() {
|
||||
Today
|
||||
</Button>
|
||||
|
||||
<div className="md:hidden">
|
||||
<Select
|
||||
value={currentView}
|
||||
onChange={(e) => changeView(e.target.value as CalendarView)}
|
||||
className="h-8 text-sm w-auto pr-8 md:hidden"
|
||||
className="h-8 text-sm w-auto pr-8"
|
||||
>
|
||||
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||
<option key={view} value={view}>{label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden">
|
||||
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||
|
||||
@ -50,8 +50,10 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const hasCurrentEvent = events.some((e) => getEventTimeState(e, clientNow) === 'current');
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
||||
<Card className={cn('hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200', hasCurrentEvent && 'animate-card-breathe')}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { Bell, Plus, Calendar as CalIcon, ListTodo, RefreshCw } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
|
||||
@ -42,6 +42,38 @@ export default function DashboardPage() {
|
||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [clockNow, setClockNow] = useState(() => new Date());
|
||||
|
||||
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function startClock() {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
setClockNow(new Date());
|
||||
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||
timeoutId = setTimeout(() => {
|
||||
setClockNow(new Date());
|
||||
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
|
||||
}, msUntilNextMinute);
|
||||
}
|
||||
|
||||
startClock();
|
||||
|
||||
// Re-sync when tab becomes visible again (after sleep/background throttle)
|
||||
function handleVisibility() {
|
||||
if (document.visibilityState === 'visible') startClock();
|
||||
}
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Click outside to close dropdown
|
||||
useEffect(() => {
|
||||
@ -160,7 +192,12 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
const updatedAgo = dataUpdatedAt
|
||||
? formatDistanceToNow(new Date(dataUpdatedAt), { addSuffix: true })
|
||||
? (() => {
|
||||
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins === 1) return '1 min ago';
|
||||
return `${mins} min ago`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
@ -173,7 +210,9 @@ export default function DashboardPage() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{format(new Date(), 'EEEE, MMMM d, yyyy')}
|
||||
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
||||
{format(clockNow, 'EEEE, MMMM d, yyyy')}
|
||||
</p>
|
||||
{updatedAgo && (
|
||||
<>
|
||||
|
||||
@ -48,8 +48,10 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
|
||||
onError: () => toast.error('Failed to complete todo'),
|
||||
});
|
||||
|
||||
const hasOverdue = todos.some((t) => isPast(endOfDay(new Date(t.due_date))));
|
||||
|
||||
return (
|
||||
<Card className="h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
||||
<Card className={cn('h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200', hasOverdue && 'animate-card-breathe')}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||
|
||||
@ -30,6 +30,8 @@ const typeConfig: Record<string, { hoverGlow: string; pillBg: string; pillText:
|
||||
reminder: { hoverGlow: 'hover:bg-orange-500/[0.08]', pillBg: 'bg-orange-500/15', pillText: 'text-orange-400', label: 'REMINDER' },
|
||||
};
|
||||
|
||||
// Snooze "Tomorrow" targets 9 AM — a reasonable default morning time.
|
||||
// Could be made configurable via user settings in the future.
|
||||
function getMinutesUntilTomorrowMorning(): number {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9, 0, 0);
|
||||
@ -251,26 +253,26 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
|
||||
<button
|
||||
onClick={() => toggleDay(dateKey)}
|
||||
className={cn(
|
||||
'sticky top-0 bg-card z-10 w-full flex items-center gap-1.5 pb-1.5 border-b border-border cursor-pointer select-none',
|
||||
groupIdx === 0 ? 'pt-0' : 'pt-3'
|
||||
'sticky top-0 z-10 w-full flex items-center gap-1.5 py-0.5 border-b border-border cursor-pointer select-none leading-none',
|
||||
groupIdx === 0 ? 'pt-0' : 'mt-2'
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 text-muted-foreground transition-transform duration-150',
|
||||
'h-2.5 w-2.5 text-muted-foreground/60 transition-transform duration-150 shrink-0',
|
||||
!isCollapsed && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold uppercase tracking-wider',
|
||||
isTodayGroup ? 'text-accent' : 'text-muted-foreground'
|
||||
'text-[10px] font-semibold uppercase tracking-wider leading-none',
|
||||
isTodayGroup ? 'text-accent' : 'text-muted-foreground/70'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{isCollapsed && (
|
||||
<span className="text-[10px] text-muted-foreground font-normal ml-auto mr-1">
|
||||
<span className="text-[9px] text-muted-foreground/50 font-normal ml-auto mr-1 leading-none">
|
||||
{dayItems.length} {dayItems.length === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
52
frontend/src/components/layout/AppAmbientBackground.tsx
Normal file
52
frontend/src/components/layout/AppAmbientBackground.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Global ambient background effect.
|
||||
*
|
||||
* Uses CSS radial gradients with animated position shifting to create
|
||||
* a living, breathing atmosphere behind all page content.
|
||||
* The accent color is read from CSS custom properties so it adapts to any theme.
|
||||
*
|
||||
* Also renders a noise texture for tactile depth and a radial vignette
|
||||
* to darken edges and draw focus to center content.
|
||||
*/
|
||||
|
||||
const NOISE_SVG = `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`;
|
||||
|
||||
export default function AppAmbientBackground() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true">
|
||||
{/* Animated gradient orbs — oversized so drift never clips at visible edges */}
|
||||
<div
|
||||
className="absolute animate-drift-1"
|
||||
style={{
|
||||
inset: '-100px',
|
||||
willChange: 'transform',
|
||||
background: 'radial-gradient(ellipse 90% 70% at 30% 20%, hsl(var(--accent-color) / 0.45) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute animate-drift-2"
|
||||
style={{
|
||||
inset: '-100px',
|
||||
willChange: 'transform',
|
||||
background: 'radial-gradient(ellipse 80% 60% at 75% 75%, hsl(var(--accent-color) / 0.35) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
{/* Noise texture */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
backgroundImage: NOISE_SVG,
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundSize: '256px 256px',
|
||||
}}
|
||||
/>
|
||||
{/* Radial vignette */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.30) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { LockProvider } from '@/hooks/useLock';
|
||||
import { NotificationProvider } from '@/hooks/useNotifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Sidebar from './Sidebar';
|
||||
import AppAmbientBackground from './AppAmbientBackground';
|
||||
import LockOverlay from './LockOverlay';
|
||||
import NotificationToaster from '@/components/notifications/NotificationToaster';
|
||||
|
||||
@ -33,15 +34,16 @@ export default function AppLayout() {
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative ambient-glass">
|
||||
<AppAmbientBackground />
|
||||
{/* Mobile header */}
|
||||
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||
<div className="relative z-10 flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto mobile-scale">
|
||||
<main className="relative z-10 flex-1 overflow-y-auto mobile-scale">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,7 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: 'bg-accent text-accent-foreground hover:bg-accent/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent/10 hover:text-accent',
|
||||
outline: 'border border-input bg-transparent hover:bg-accent/10 hover:text-accent',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent/10 hover:text-accent',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
|
||||
@ -6,10 +6,10 @@
|
||||
:root {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 5%;
|
||||
--card: 0 0% 8%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--card-elevated: 0 0% 7%;
|
||||
--popover: 0 0% 5%;
|
||||
--card-elevated: 0 0% 11%;
|
||||
--popover: 0 0% 8%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: var(--accent-h) var(--accent-s) var(--accent-l);
|
||||
--primary-foreground: 0 0% 98%;
|
||||
@ -93,8 +93,8 @@
|
||||
--fc-event-bg-color: hsl(var(--accent-color));
|
||||
--fc-event-border-color: hsl(var(--accent-color));
|
||||
--fc-event-text-color: hsl(0 0% 98%);
|
||||
--fc-page-bg-color: hsl(0 0% 3.9%);
|
||||
--fc-neutral-bg-color: hsl(0 0% 5%);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-neutral-bg-color: hsl(0 0% 8% / 0.65);
|
||||
--fc-neutral-text-color: hsl(0 0% 98%);
|
||||
--fc-list-event-hover-bg-color: hsl(0 0% 10%);
|
||||
--fc-today-bg-color: hsl(var(--accent-color) / 0.08);
|
||||
@ -144,7 +144,7 @@
|
||||
}
|
||||
|
||||
.fc .fc-col-header-cell {
|
||||
background-color: hsl(0 0% 5%);
|
||||
background-color: hsl(0 0% 8% / 0.65);
|
||||
border-color: var(--fc-border-color);
|
||||
}
|
||||
|
||||
@ -195,7 +195,9 @@
|
||||
|
||||
/* ── FullCalendar "+more" popover fixes ── */
|
||||
.fc .fc-more-popover {
|
||||
background-color: hsl(0 0% 5%);
|
||||
background-color: hsl(0 0% 8% / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-color: hsl(0 0% 14.9%);
|
||||
border-radius: 0.5rem;
|
||||
min-width: 220px;
|
||||
@ -203,7 +205,7 @@
|
||||
}
|
||||
|
||||
.fc .fc-more-popover .fc-popover-header {
|
||||
background-color: hsl(0 0% 7%);
|
||||
background-color: hsl(0 0% 11% / 0.8);
|
||||
color: hsl(0 0% 98%);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
@ -455,6 +457,25 @@ form[data-submitted] input:invalid + button {
|
||||
.animate-slide-in-row { animation: slide-in-row 250ms ease-out both; }
|
||||
.animate-content-reveal { animation: content-reveal 400ms ease-out both; }
|
||||
|
||||
/* ── Dashboard ambient layers ── */
|
||||
|
||||
/* Glassmorphism — cards become semi-transparent to let ambient show through */
|
||||
.ambient-glass .bg-card {
|
||||
background-color: hsl(0 0% 8% / 0.65) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Card breathing glow — data-aware "alive" pulse for urgent cards */
|
||||
@keyframes card-breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--accent-color) / 0.05); }
|
||||
50% { box-shadow: 0 0 16px 2px hsl(var(--accent-color) / 0.10); }
|
||||
}
|
||||
|
||||
.animate-card-breathe {
|
||||
animation: card-breathe 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Respect reduced motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user