-
Notifications
You must be signed in to change notification settings - Fork 0
Merge pull request #53 from frlund3/claude/review-project-164jY #54
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -59,6 +59,12 @@ body { | |||||||||||||||||||||||||||||||||||||
| padding-right: env(safe-area-inset-right); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /* Smooth theme transition */ | ||||||||||||||||||||||||||||||||||||||
| .theme-transitioning, | ||||||||||||||||||||||||||||||||||||||
| .theme-transitioning * { | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
| .theme-transitioning * { | |
| .theme-transitioning .bg-theme, | |
| .theme-transitioning .bg-theme-card, | |
| .theme-transitioning .bg-theme-card-hover, | |
| .theme-transitioning .bg-theme-card-inner, | |
| .theme-transitioning .bg-theme-sidebar, | |
| .theme-transitioning .bg-theme-input, | |
| .theme-transitioning .border-theme, | |
| .theme-transitioning .border-theme-input, | |
| .theme-transitioning .text-theme, | |
| .theme-transitioning .text-theme-secondary, | |
| .theme-transitioning .text-theme-muted, | |
| .theme-transitioning .text-theme-dim, | |
| .theme-transitioning .bg-theme-overlay, | |
| .theme-transitioning .hover\:bg-theme-card-hover:hover, | |
| .theme-transitioning .hover\:text-theme:hover, | |
| .theme-transitioning .bg-theme\/95, | |
| .theme-transitioning .divide-theme > :not([hidden]) ~ :not([hidden]) { |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| 'use client' | ||
|
|
||
| import { ErrorBoundary } from '@/components/ui/ErrorBoundary' | ||
|
|
||
| export function ErrorBoundaryProvider({ children }: { children: React.ReactNode }) { | ||
| return <ErrorBoundary>{children}</ErrorBoundary> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,7 +46,10 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { | |
| }, [theme, mounted]) | ||
|
|
||
| const toggleTheme = () => { | ||
| // Add transition class for smooth color change, remove after transition | ||
| document.documentElement.classList.add('theme-transitioning') | ||
| setTheme(prev => prev === 'dark' ? 'light' : 'dark') | ||
| setTimeout(() => document.documentElement.classList.remove('theme-transitioning'), 250) | ||
|
||
| } | ||
|
|
||
| return ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| 'use client' | ||
|
|
||
| interface EmptyStateProps { | ||
| icon?: 'search' | 'filter' | 'list' | 'fire' | ||
| title: string | ||
| description?: string | ||
| action?: { | ||
| label: string | ||
| onClick: () => void | ||
| } | ||
| } | ||
|
|
||
| const icons = { | ||
| search: ( | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> | ||
| ), | ||
| filter: ( | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" /> | ||
| ), | ||
| list: ( | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> | ||
| ), | ||
| fire: ( | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" /> | ||
| ), | ||
| } | ||
|
|
||
| export function EmptyState({ icon = 'list', title, description, action }: EmptyStateProps) { | ||
| return ( | ||
| <div className="text-center py-12 px-4"> | ||
| <svg className="w-12 h-12 text-theme-dim mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| {icons[icon]} | ||
| </svg> | ||
| <h3 className="text-sm font-semibold text-theme-secondary mb-1">{title}</h3> | ||
| {description && <p className="text-xs text-theme-muted mb-4 max-w-sm mx-auto">{description}</p>} | ||
| {action && ( | ||
| <button | ||
| onClick={action.onClick} | ||
| className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors" | ||
| > | ||
| {action.label} | ||
| </button> | ||
| )} | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| 'use client' | ||
|
|
||
| import { Component, type ReactNode } from 'react' | ||
|
|
||
| interface Props { | ||
| children: ReactNode | ||
| fallback?: ReactNode | ||
| } | ||
|
|
||
| interface State { | ||
| hasError: boolean | ||
| error: Error | null | ||
| } | ||
|
|
||
| export class ErrorBoundary extends Component<Props, State> { | ||
| constructor(props: Props) { | ||
| super(props) | ||
| this.state = { hasError: false, error: null } | ||
| } | ||
|
|
||
| static getDerivedStateFromError(error: Error): State { | ||
| return { hasError: true, error } | ||
| } | ||
|
|
||
| componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { | ||
| console.error('[ErrorBoundary]', error, errorInfo) | ||
| } | ||
|
|
||
| render() { | ||
| if (this.state.hasError) { | ||
| if (this.props.fallback) return this.props.fallback | ||
|
|
||
| return ( | ||
| <div className="rounded-xl bg-theme-card border border-theme p-6 text-center"> | ||
| <svg className="w-10 h-10 text-red-400 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> | ||
| </svg> | ||
| <h3 className="text-sm font-semibold text-theme mb-1">Noe gikk galt</h3> | ||
| <p className="text-xs text-theme-muted mb-3"> | ||
| {this.state.error?.message || 'En uventet feil oppstod i denne komponenten.'} | ||
| </p> | ||
| <button | ||
| onClick={() => this.setState({ hasError: false, error: null })} | ||
| className="px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-xs font-medium transition-colors" | ||
| > | ||
| Prøv igjen | ||
| </button> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| return this.props.children | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { useState, useEffect } from 'react' | ||
|
|
||
| export function useDebounce<T>(value: T, delay: number): T { | ||
| const [debouncedValue, setDebouncedValue] = useState(value) | ||
|
|
||
| useEffect(() => { | ||
| const timer = setTimeout(() => setDebouncedValue(value), delay) | ||
| return () => clearTimeout(timer) | ||
| }, [value, delay]) | ||
|
|
||
| return debouncedValue | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { useState, useEffect, useCallback, useRef } from 'react' | ||
|
|
||
| interface UsePullToRefreshOptions { | ||
| onRefresh: () => Promise<void> | void | ||
| threshold?: number | ||
| } | ||
|
|
||
| export function usePullToRefresh({ onRefresh, threshold = 80 }: UsePullToRefreshOptions) { | ||
| const [pulling, setPulling] = useState(false) | ||
| const [refreshing, setRefreshing] = useState(false) | ||
| const [pullDistance, setPullDistance] = useState(0) | ||
| const startY = useRef(0) | ||
| const containerRef = useRef<HTMLDivElement>(null) | ||
|
|
||
| const handleTouchStart = useCallback((e: TouchEvent) => { | ||
| if (window.scrollY === 0) { | ||
| startY.current = e.touches[0].clientY | ||
| setPulling(true) | ||
| } | ||
| }, []) | ||
|
|
||
| const handleTouchMove = useCallback((e: TouchEvent) => { | ||
| if (!pulling || refreshing) return | ||
| const distance = e.touches[0].clientY - startY.current | ||
| if (distance > 0) { | ||
| setPullDistance(Math.min(distance * 0.5, threshold * 1.5)) | ||
| } | ||
| }, [pulling, refreshing, threshold]) | ||
|
|
||
| const handleTouchEnd = useCallback(async () => { | ||
| if (!pulling) return | ||
| setPulling(false) | ||
| if (pullDistance >= threshold && !refreshing) { | ||
| setRefreshing(true) | ||
| try { | ||
| await onRefresh() | ||
| } finally { | ||
| setRefreshing(false) | ||
| } | ||
| } | ||
| setPullDistance(0) | ||
| }, [pulling, pullDistance, threshold, refreshing, onRefresh]) | ||
|
|
||
| useEffect(() => { | ||
| const el = containerRef.current | ||
| if (!el) return | ||
| el.addEventListener('touchstart', handleTouchStart, { passive: true }) | ||
| el.addEventListener('touchmove', handleTouchMove, { passive: true }) | ||
| el.addEventListener('touchend', handleTouchEnd) | ||
| return () => { | ||
| el.removeEventListener('touchstart', handleTouchStart) | ||
| el.removeEventListener('touchmove', handleTouchMove) | ||
| el.removeEventListener('touchend', handleTouchEnd) | ||
| } | ||
| }, [handleTouchStart, handleTouchMove, handleTouchEnd]) | ||
|
|
||
| const progress = Math.min(pullDistance / threshold, 1) | ||
|
|
||
| return { containerRef, refreshing, pullDistance, progress } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding tabIndex={0} to a div makes it keyboard focusable but doesn't provide adequate context for screen reader users. Consider adding an aria-label or aria-labelledby attribute to describe what this interactive list represents, such as 'Hendelsesliste' or 'Liste over hendelser'.