mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95203426a3 | ||
|
|
b673d54ede | ||
|
|
42c8d14d6d | ||
|
|
3ac311c3fd | ||
|
|
1a286a99f4 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## Version 0.2.19
|
||||
|
||||
### Fixed
|
||||
|
||||
* settings button not working
|
||||
* fixed delete dialog modal blocks page interaction (#149)
|
||||
* disable submit button when frequency is invaid
|
||||
|
||||
## Version 0.2.18
|
||||
|
||||
### Improved
|
||||
|
||||
* nicer loading UI (#147)
|
||||
* header and navigation code refactor
|
||||
|
||||
## Version 0.2.17
|
||||
|
||||
### Fixed
|
||||
|
||||
* fix emoji selector (#142)
|
||||
* fix about modal (#145)
|
||||
|
||||
## Version 0.2.16
|
||||
|
||||
### Improved
|
||||
|
||||
* move delete user button to user form
|
||||
* disable deleting user on demo instance
|
||||
|
||||
## Version 0.2.15
|
||||
|
||||
### Improved
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# HabitTrove
|
||||
|
||||

|
||||
|
||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||
|
||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||
|
||||
@@ -2,7 +2,6 @@ import './globals.css'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { DM_Sans } from 'next/font/google'
|
||||
import { JotaiProvider } from '@/components/jotai-providers'
|
||||
import { Suspense } from 'react'
|
||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
|
||||
import Layout from '@/components/Layout'
|
||||
@@ -11,6 +10,8 @@ import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { Suspense } from 'react'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
|
||||
|
||||
// Inter (clean, modern, excellent readability)
|
||||
@@ -75,7 +76,7 @@ export default async function RootLayout({
|
||||
}}
|
||||
/>
|
||||
<JotaiProvider>
|
||||
<Suspense fallback="loading">
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<JotaiHydrate
|
||||
initialValues={{
|
||||
settings: initialSettings,
|
||||
|
||||
@@ -11,17 +11,16 @@ import ChangelogModal from "./ChangelogModal"
|
||||
import { useState } from "react"
|
||||
|
||||
interface AboutModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
export default function AboutModal({ onClose }: AboutModalProps) {
|
||||
const t = useTranslations('AboutModal')
|
||||
const version = packageJson.version
|
||||
const [changelogOpen, setChangelogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle aria-label={t('dialogArisLabel')}></DialogTitle>
|
||||
|
||||
@@ -8,26 +8,16 @@ import { settingsAtom, browserSettingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Info, SmilePlus, Zap } from 'lucide-react'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { Habit, SafeUser } from '@/lib/types'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import { convertHumanReadableFrequencyToMachineReadable, convertMachineReadableFrequencyToHumanReadable, d2s, d2t, serializeRRule } from '@/lib/utils'
|
||||
import { INITIAL_DUE, INITIAL_RECURRENCE_RULE, QUICK_DATES, RECURRENCE_RULE_MAP, MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
import * as chrono from 'chrono-node';
|
||||
import { DateTime } from 'luxon'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
|
||||
interface AddEditHabitModalProps {
|
||||
@@ -46,15 +36,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
const [targetCompletions, setTargetCompletions] = useState(habit?.targetCompletions || 1)
|
||||
const isRecurRule = !isTask
|
||||
// Initialize ruleText with the actual frequency string or default, not the display text
|
||||
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||
const initialRuleText = habit?.frequency ? convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
}) : (isRecurRule ? INITIAL_RECURRENCE_RULE : INITIAL_DUE);
|
||||
const [ruleText, setRuleText] = useState<string>(initialRuleText)
|
||||
const { currentUser } = useHelpers()
|
||||
const [isQuickDatesOpen, setIsQuickDatesOpen] = useState(false)
|
||||
const [ruleError, setRuleError] = useState<string | null>(null); // State for validation message
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null); // State for validation message
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
@@ -119,33 +109,15 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SmilePlus className="h-8 w-8" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
setName(prev => {
|
||||
// Add space before emoji if there isn't one already
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji.native}`;
|
||||
})
|
||||
// Focus back on input after selection
|
||||
const input = document.getElementById('name') as HTMLInputElement
|
||||
input?.focus()
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<EmojiPickerButton
|
||||
inputIdToFocus="name"
|
||||
onEmojiSelect={(emoji) => {
|
||||
setName(prev => {
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji}`;
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
@@ -211,9 +183,10 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
<div className="col-start-2 col-span-3 text-sm">
|
||||
{(() => {
|
||||
let displayText = '';
|
||||
let errorMessage: string | null = null;
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
errorMessage = message;
|
||||
if (message !== errorMessage) { // Only update if it changed to avoid re-renders
|
||||
setErrorMessage(message);
|
||||
}
|
||||
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||
|
||||
return (
|
||||
@@ -348,7 +321,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Button type="submit" disabled={!!errorMessage}>
|
||||
{habit
|
||||
? t('saveChangesButton')
|
||||
: t(isTask ? 'addTaskButton' : 'addHabitButton')}
|
||||
|
||||
@@ -9,12 +9,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { SmilePlus, Info } from 'lucide-react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import EmojiPickerButton from './EmojiPickerButton'
|
||||
import { MAX_COIN_LIMIT } from '@/lib/constants'
|
||||
|
||||
interface AddEditWishlistItemModalProps {
|
||||
@@ -114,7 +110,7 @@ export default function AddEditWishlistItemModal({
|
||||
} else {
|
||||
addWishlistItem(itemData)
|
||||
}
|
||||
|
||||
|
||||
setIsOpen(false)
|
||||
setEditingItem(null)
|
||||
}
|
||||
@@ -139,29 +135,15 @@ export default function AddEditWishlistItemModal({
|
||||
className="flex-1"
|
||||
required
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<SmilePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
setName(prev => `${prev}${emoji.native}`)
|
||||
// Focus back on input after selection
|
||||
const input = document.getElementById('name') as HTMLInputElement
|
||||
input?.focus()
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<EmojiPickerButton
|
||||
inputIdToFocus="name"
|
||||
onEmojiSelect={(emoji) => {
|
||||
setName(prev => {
|
||||
const space = prev.length > 0 && !prev.endsWith(' ') ? ' ' : '';
|
||||
return `${prev}${space}${emoji}`;
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
@@ -296,13 +278,13 @@ export default function AddEditWishlistItemModal({
|
||||
<Avatar
|
||||
key={user.id}
|
||||
className={`h-8 w-8 border-2 cursor-pointer
|
||||
${selectedUserIds.includes(user.id)
|
||||
? 'border-primary'
|
||||
${selectedUserIds.includes(user.id)
|
||||
? 'border-primary'
|
||||
: 'border-muted'
|
||||
}`}
|
||||
title={user.username}
|
||||
onClick={() => {
|
||||
setSelectedUserIds(prev =>
|
||||
setSelectedUserIds(prev =>
|
||||
prev.includes(user.id)
|
||||
? prev.filter(id => id !== user.id)
|
||||
: [...prev, user.id]
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { ReactNode, Suspense, useEffect, useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { pomodoroAtom, userSelectAtom } from '@/lib/atoms'
|
||||
import { aboutOpenAtom, pomodoroAtom, userSelectAtom } from '@/lib/atoms'
|
||||
import PomodoroTimer from './PomodoroTimer'
|
||||
import UserSelectModal from './UserSelectModal'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import AboutModal from './AboutModal'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||
const [pomo] = useAtom(pomodoroAtom)
|
||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||
const { data: session, status } = useSession()
|
||||
const currentUserId = session?.user.id
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// block client-side hydration until mounted (this is crucial to wait for all jotai atoms to load), to prevent SSR hydration errors in the children components
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return
|
||||
@@ -20,6 +29,9 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [currentUserId, status, userSelect])
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
@@ -27,7 +39,10 @@ export default function ClientWrapper({ children }: { children: ReactNode }) {
|
||||
<PomodoroTimer />
|
||||
)}
|
||||
{userSelect && (
|
||||
<UserSelectModal onClose={() => setUserSelect(false)}/>
|
||||
<UserSelectModal onClose={() => setUserSelect(false)} />
|
||||
)}
|
||||
{aboutOpen && (
|
||||
<AboutModal onClose={() => setAboutOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
42
components/DesktopNavDisplay.tsx
Normal file
42
components/DesktopNavDisplay.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import Link from 'next/link'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface DesktopNavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function DesktopNavDisplay({ navItems, className }: DesktopNavDisplayProps) {
|
||||
// Filter for items relevant to desktop view, typically 'main' position
|
||||
const desktopNavItems = navItems.filter(item => item.position === 'main');
|
||||
|
||||
return (
|
||||
<div className={`hidden lg:flex lg:flex-shrink-0 ${className || ''}`}>
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{desktopNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label} // Assuming labels are unique
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/EmojiPickerButton.tsx
Normal file
51
components/EmojiPickerButton.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { SmilePlus } from 'lucide-react'
|
||||
import data from '@emoji-mart/data'
|
||||
import Picker from '@emoji-mart/react'
|
||||
|
||||
interface EmojiPickerButtonProps {
|
||||
onEmojiSelect: (emoji: string) => void
|
||||
inputIdToFocus?: string // Optional: ID of the input to focus after selection
|
||||
}
|
||||
|
||||
export default function EmojiPickerButton({ onEmojiSelect, inputIdToFocus }: EmojiPickerButtonProps) {
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={isEmojiPickerOpen} onOpenChange={setIsEmojiPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8" // Consistent sizing
|
||||
>
|
||||
<SmilePlus className="h-4 w-4" /> {/* Consistent icon size */}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[300px] p-0"
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (inputIdToFocus) {
|
||||
event.preventDefault();
|
||||
const input = document.getElementById(inputIdToFocus) as HTMLInputElement;
|
||||
input?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(emoji: { native: string }) => {
|
||||
onEmojiSelect(emoji.native);
|
||||
setIsEmojiPickerOpen(false);
|
||||
// Focus is handled by onCloseAutoFocus
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ interface HabitItemProps {
|
||||
|
||||
const renderUserAvatars = (habit: Habit, currentUser: User | null, usersData: { users: User[] }) => {
|
||||
if (!habit.userIds || habit.userIds.length <= 1) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex -space-x-2 ml-2 flex-shrink-0">
|
||||
{habit.userIds?.filter((u) => u !== currentUser?.id).map(userId => {
|
||||
@@ -113,11 +113,13 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
{t('whenLabel', { frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})})}
|
||||
{t('whenLabel', {
|
||||
frequency: convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className={`h-4 w-4 mr-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-yellow-400'}`} />
|
||||
@@ -190,7 +192,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
<span className="ml-2">{t('editButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { coinsAtom, settingsAtom, browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Menu, Settings, User, Info, Coins } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import AboutModal from './AboutModal'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Profile } from './Profile'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import HeaderActions from './HeaderActions'
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||
|
||||
export default function Header({ className }: HeaderProps) {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const { balance } = useCoins()
|
||||
return (
|
||||
<>
|
||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||
@@ -40,23 +16,7 @@ export default function Header({ className }: HeaderProps) {
|
||||
<Link href="/" className="mr-3 sm:mr-4">
|
||||
<Logo />
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
||||
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
||||
<div className="flex items-baseline gap-1 sm:gap-2">
|
||||
<FormattedNumber
|
||||
amount={balance}
|
||||
settings={settings}
|
||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
||||
/>
|
||||
<div className="hidden sm:block">
|
||||
<TodayEarnedCoins />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<NotificationBell />
|
||||
<Profile />
|
||||
</div>
|
||||
<HeaderActions />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
38
components/HeaderActions.tsx
Normal file
38
components/HeaderActions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useAtom } from 'jotai'
|
||||
import { settingsAtom } from '@/lib/atoms'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { FormattedNumber } from '@/components/FormattedNumber'
|
||||
import { Coins } from 'lucide-react'
|
||||
import NotificationBell from './NotificationBell'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Profile } from './Profile'
|
||||
|
||||
const TodayEarnedCoins = dynamic(() => import('./TodayEarnedCoins'), { ssr: false })
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const { balance } = useCoins()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Link href="/coins" className="flex items-center gap-1 sm:gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors border border-gray-200 dark:border-gray-600">
|
||||
<Coins className="h-5 w-5 text-yellow-500 dark:text-yellow-400" />
|
||||
<div className="flex items-baseline gap-1 sm:gap-2">
|
||||
<FormattedNumber
|
||||
amount={balance}
|
||||
settings={settings}
|
||||
className="text-gray-800 dark:text-gray-100 font-medium text-lg"
|
||||
/>
|
||||
<div className="hidden sm:block">
|
||||
<TodayEarnedCoins />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<NotificationBell />
|
||||
<Profile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
components/LoadingSpinner.tsx
Normal file
61
components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Coins } from 'lucide-react';
|
||||
import { Logo } from '@/components/Logo';
|
||||
|
||||
const subtexts = [
|
||||
"Unearthing your treasures",
|
||||
"Polishing your gems",
|
||||
"Mining for good habits",
|
||||
"Stumbling upon brilliance",
|
||||
"Discovering your potential",
|
||||
"Crafting your success story",
|
||||
"Forging new paths",
|
||||
"Summoning success",
|
||||
"Brewing brilliance",
|
||||
"Charging up your awesome",
|
||||
"Assembling achievements",
|
||||
"Leveling up your day",
|
||||
"Questing for quality",
|
||||
"Unlocking awesomeness",
|
||||
"Plotting your progress",
|
||||
];
|
||||
|
||||
const LoadingSpinner: React.FC = () => {
|
||||
const [currentSubtext, setCurrentSubtext] = useState<string>('Loading your data');
|
||||
const [animatedDots, setAnimatedDots] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * subtexts.length);
|
||||
setCurrentSubtext(subtexts[randomIndex]);
|
||||
|
||||
const dotAnimationInterval = setInterval(() => {
|
||||
setAnimatedDots(prevDots => {
|
||||
if (prevDots.length >= 3) {
|
||||
return '';
|
||||
}
|
||||
return prevDots + '.';
|
||||
});
|
||||
}, 200); // Adjust timing as needed
|
||||
|
||||
return () => clearInterval(dotAnimationInterval);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Coins className="h-12 w-12 animate-bounce text-yellow-500" />
|
||||
<Logo />
|
||||
{currentSubtext && (
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{currentSubtext}{animatedDots}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
45
components/MobileNavDisplay.tsx
Normal file
45
components/MobileNavDisplay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import Link from 'next/link'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface MobileNavDisplayProps {
|
||||
navItems: NavItemType[];
|
||||
isIOS: boolean;
|
||||
}
|
||||
|
||||
export default function MobileNavDisplay({ navItems, isIOS }: MobileNavDisplayProps) {
|
||||
// Filter for items relevant to mobile view, typically 'main' and 'bottom' positions
|
||||
const mobileNavItems = navItems.filter(item => item.position === 'main' || item.position === 'bottom');
|
||||
// The original code spread main and bottom items separately, effectively concatenating them.
|
||||
// If specific ordering or duplication was intended, that logic would be here.
|
||||
// For now, a simple filter and map should suffice if all items are distinct.
|
||||
// The original code: [...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')]
|
||||
// This implies that items could be in 'main' or 'bottom'. The current navItems only have 'main'.
|
||||
// A simple combined list is fine.
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-5 w-full">
|
||||
{mobileNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.label} // Assuming labels are unique
|
||||
href={item.href}
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins, Settings, Info, CheckSquare } from 'lucide-react'
|
||||
import { Home, Calendar, Gift, Coins } from 'lucide-react'
|
||||
import { useAtom } from 'jotai'
|
||||
import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, ElementType } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import AboutModal from './AboutModal'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import MobileNavDisplay from './MobileNavDisplay'
|
||||
import DesktopNavDisplay from './DesktopNavDisplay'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
export interface NavItemType {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
href: string;
|
||||
position: 'main' | 'bottom';
|
||||
}
|
||||
|
||||
interface NavigationProps {
|
||||
className?: string
|
||||
viewPort: ViewPort
|
||||
@@ -19,13 +26,12 @@ interface NavigationProps {
|
||||
|
||||
export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
const t = useTranslations('Navigation')
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const { isIOS } = useHelpers()
|
||||
|
||||
const navItems = (isTasksView: boolean) => [
|
||||
const currentNavItems: NavItemType[] = [
|
||||
{ icon: Home, label: t('dashboard'), href: '/', position: 'main' },
|
||||
{
|
||||
icon: isTasksView ? TaskIcon : HabitIcon,
|
||||
@@ -54,51 +60,12 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
}, [])
|
||||
|
||||
if (viewPort === 'mobile' && isMobileView) {
|
||||
return (
|
||||
<>
|
||||
<div className={isIOS ? "pb-20" : "pb-16"} /> {/* Add padding at the bottom to prevent content from being hidden */}
|
||||
<nav className={`lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg ${isIOS ? "pb-4" : ""}`}>
|
||||
<div className="grid grid-cols-5 w-full">
|
||||
{[...navItems(isTasksView).filter(item => item.position === 'main'), ...navItems(isTasksView).filter(item => item.position === 'bottom')].map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center justify-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</>
|
||||
)
|
||||
return <MobileNavDisplay navItems={currentNavItems} isIOS={isIOS} />
|
||||
}
|
||||
|
||||
if (viewPort === 'main' && !isMobileView) {
|
||||
return (
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
</div>
|
||||
)
|
||||
return <DesktopNavDisplay navItems={currentNavItems} className={className} />
|
||||
}
|
||||
|
||||
return null // Explicitly return null if no view matches
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import UserForm from './UserForm'
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
import { settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||
import AboutModal from "./AboutModal"
|
||||
import { aboutOpenAtom, settingsAtom, userSelectAtom } from "@/lib/atoms"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { signOut } from "@/app/actions/user"
|
||||
@@ -22,7 +21,7 @@ export function Profile() {
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [userSelect, setUserSelect] = useAtom(userSelectAtom)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [aboutOpen, setAboutOpen] = useAtom(aboutOpenAtom)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { currentUser: user } = useHelpers()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -111,27 +110,33 @@ export function Profile() {
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
{/* need the Link element to be the direct child of the DropdownMenuItem, since we are using asChild here */}
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-label={t('settingsLink')}
|
||||
className="flex items-center w-full gap-3"
|
||||
className="flex items-center justify-between w-full"
|
||||
onClick={() => setOpen(false)} // Ensure dropdown closes on click
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('settingsLink')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>{t('settingsLink')}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" asChild>
|
||||
<button
|
||||
onClick={() => setShowAbout(true)}
|
||||
className="flex items-center w-full gap-3"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
<span>{t('aboutButton')}</span>
|
||||
</button>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5" onClick={() => {
|
||||
setOpen(false); // Close the dropdown
|
||||
setAboutOpen(true); // Open the about modal
|
||||
}}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>{t('aboutButton')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-pointer px-2 py-1.5">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{t('themeLabel')}</span>
|
||||
</div>
|
||||
@@ -169,8 +174,6 @@ export function Profile() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AboutModal isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
|
||||
{/* Add the UserForm dialog */}
|
||||
{isEditing && user && (
|
||||
<Dialog open={isEditing} onOpenChange={() => setIsEditing(false)}>
|
||||
|
||||
@@ -5,6 +5,17 @@ import { passwordSchema, usernameSchema } from '@/lib/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Label } from './ui/label';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Permission } from '@/lib/types';
|
||||
@@ -58,6 +69,69 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
);
|
||||
const isEditing = !!user;
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (serverSettings.isDemo) {
|
||||
toast({
|
||||
title: t('errorTitle'),
|
||||
description: t('toastDemoDeleteDisabled'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUser && currentUser.id === user.id) {
|
||||
toast({
|
||||
title: t('errorTitle'),
|
||||
description: t('toastCannotDeleteSelf'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUsersData(prev => ({
|
||||
...prev,
|
||||
users: prev.users.filter(u => u.id !== user.id),
|
||||
}));
|
||||
toast({
|
||||
title: t('toastUserDeletedTitle'),
|
||||
description: t('toastUserDeletedDescription', { username: user.username }),
|
||||
variant: 'default'
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast({
|
||||
title: t('errorTitle'),
|
||||
description: errorData.error || t('genericError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('errorTitle'),
|
||||
description: t('networkError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -94,11 +168,11 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
setUsersData(prev => ({
|
||||
...prev,
|
||||
users: prev.users.map(u =>
|
||||
u.id === user.id ? {
|
||||
...u,
|
||||
username,
|
||||
avatarPath,
|
||||
permissions,
|
||||
u.id === user.id ? {
|
||||
...u,
|
||||
username,
|
||||
avatarPath,
|
||||
permissions,
|
||||
isAdmin,
|
||||
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
|
||||
} : u
|
||||
@@ -248,7 +322,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
<p className="text-sm text-red-500">{t('demoPasswordDisabledMessage')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="disable-password"
|
||||
@@ -264,7 +338,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
<p className="text-sm text-red-500 bg-red-50 dark:bg-red-950/50 p-2 rounded">{error}</p>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{currentUser && currentUser.isAdmin && <PermissionSelector
|
||||
permissions={permissions}
|
||||
isAdmin={isAdmin}
|
||||
@@ -275,6 +349,38 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{isEditing && (
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto"
|
||||
disabled={serverSettings.isDemo || isDeleting}
|
||||
>
|
||||
{isDeleting ? t('deletingButtonText') : t('deleteAccountButton')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteUserConfirmation', { username: user.username })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteUser}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -36,62 +36,19 @@ function UserCard({
|
||||
onEdit,
|
||||
showEdit,
|
||||
isCurrentUser,
|
||||
currentLoggedInUserId, // For "don't delete self" check
|
||||
onUserDeleted // Callback to update usersAtom
|
||||
}: {
|
||||
user: User,
|
||||
onSelect: () => void,
|
||||
onEdit: () => void,
|
||||
showEdit: boolean,
|
||||
isCurrentUser: boolean,
|
||||
currentLoggedInUserId?: string,
|
||||
onUserDeleted: (userId: string) => void,
|
||||
}) {
|
||||
const t = useTranslations('UserSelectModal');
|
||||
const tWarning = useTranslations('Warning');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await fetch('/api/user/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: t('deleteUserSuccessTitle'),
|
||||
description: t('deleteUserSuccessDescription', { username: user.username }),
|
||||
});
|
||||
onUserDeleted(user.id);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast({
|
||||
title: t('deleteUserErrorTitle'),
|
||||
description: errorData.error || t('genericError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('deleteUserErrorTitle'),
|
||||
description: t('networkError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={user.id} className="relative group">
|
||||
<button
|
||||
onClick={onSelect}
|
||||
disabled={isDeleting} // Disable main button while deleting this user
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors w-full",
|
||||
isCurrentUser && "ring-2 ring-primary"
|
||||
@@ -119,48 +76,12 @@ function UserCard({
|
||||
e.stopPropagation(); // Prevent card selection
|
||||
onEdit();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||
title={t('editUserTooltip')}
|
||||
>
|
||||
<UserRoundPen className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{showEdit && user.id !== currentLoggedInUserId && (
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent card selection
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="p-1 rounded-full bg-red-200 hover:bg-red-300 dark:bg-red-700 dark:hover:bg-red-600 transition-colors text-red-600 dark:text-red-300"
|
||||
title={t('deleteUserTooltip')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{tWarning('areYouSure')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('deleteUserConfirmation', { username: user.username })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(false);}} disabled={isDeleting}>{tWarning('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteUser();}}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? t('deletingButtonText') : t('confirmDeleteButtonText')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -190,14 +111,12 @@ function UserSelectionView({
|
||||
onUserSelect,
|
||||
onEditUser,
|
||||
onCreateUser,
|
||||
onUserDeleted, // Pass through the delete handler
|
||||
}: {
|
||||
users: User[],
|
||||
currentUserFromHook?: SafeUser,
|
||||
onUserSelect: (userId: string) => void,
|
||||
onEditUser: (userId: string) => void,
|
||||
onCreateUser: () => void,
|
||||
onUserDeleted: (userId: string) => void,
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4 p-2 max-h-80 overflow-y-auto">
|
||||
@@ -211,8 +130,6 @@ function UserSelectionView({
|
||||
onEdit={() => onEditUser(user.id)}
|
||||
showEdit={!!currentUserFromHook?.isAdmin}
|
||||
isCurrentUser={false} // This card isn't the currently logged-in user for switching TO
|
||||
currentLoggedInUserId={currentUserFromHook?.id} // For the "don't delete self" check
|
||||
onUserDeleted={onUserDeleted}
|
||||
/>
|
||||
))}
|
||||
{currentUserFromHook?.isAdmin && <AddUserButton onClick={onCreateUser} />}
|
||||
@@ -230,12 +147,6 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
||||
const users = usersData.users;
|
||||
const { currentUser } = useHelpers();
|
||||
|
||||
const handleUserDeleted = (userIdToDelete: string) => {
|
||||
setUsersData(prevData => ({
|
||||
...prevData,
|
||||
users: prevData.users.filter(u => u.id !== userIdToDelete)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUserSelect = (userId: string) => {
|
||||
setSelectedUser(userId);
|
||||
@@ -281,7 +192,6 @@ export default function UserSelectModal({ onClose }: { onClose: () => void }) {
|
||||
onUserSelect={handleUserSelect}
|
||||
onEditUser={handleEditUser}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUserDeleted={handleUserDeleted}
|
||||
/>
|
||||
) : isCreating || isEditing ? (
|
||||
<UserForm
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function WishlistItem({
|
||||
<span className="ml-2">{t('editButton')}</span>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
@@ -166,7 +166,7 @@ export default function WishlistItem({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="sm:hidden" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400 cursor-pointer"
|
||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||
onClick={onDelete}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
|
||||
@@ -107,6 +107,7 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
})
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
export const aboutOpenAtom = atom<boolean>(false)
|
||||
|
||||
// Derived atom for completion cache
|
||||
export const completionCacheAtom = atom((get) => {
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
"actionUpdate": "aktualisieren",
|
||||
"actionCreate": "erstellen",
|
||||
"errorFailedUserAction": "Fehler beim {action} des Benutzers",
|
||||
"toastDemoDeleteDisabled": "Löschen ist in der Demo-Instanz deaktiviert",
|
||||
"toastCannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
|
||||
"confirmDeleteUser": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
|
||||
"toastUserDeletedTitle": "Benutzer gelöscht",
|
||||
"toastUserDeletedDescription": "Benutzer {username} wurde erfolgreich gelöscht",
|
||||
"toastDeleteUserFailed": "Fehler beim Löschen des Benutzers: {error}",
|
||||
"errorTitle": "Fehler",
|
||||
"errorFileSizeLimit": "Die Dateigröße muss kleiner als 5MB sein",
|
||||
"toastAvatarUploadedTitle": "Avatar hochgeladen",
|
||||
@@ -287,7 +293,13 @@
|
||||
"disablePasswordLabel": "Passwort deaktivieren",
|
||||
"cancelButton": "Abbrechen",
|
||||
"saveChangesButton": "Änderungen speichern",
|
||||
"createUserButton": "Benutzer erstellen"
|
||||
"createUserButton": "Benutzer erstellen",
|
||||
"deleteAccountButton": "Konto löschen",
|
||||
"deletingButtonText": "Wird gelöscht...",
|
||||
"areYouSure": "Sind Sie sicher?",
|
||||
"deleteUserConfirmation": "Sind Sie sicher, dass Sie den Benutzer {username} löschen möchten?",
|
||||
"cancel": "Abbrechen",
|
||||
"confirmDeleteButtonText": "Löschen"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "Gewohnheiten",
|
||||
@@ -411,6 +423,7 @@
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "{amount} Münzen hinzugefügt",
|
||||
"invalidAmountTitle": "Ungültiger Betrag",
|
||||
"invalidAmountDescription": "Bitte geben Sie eine gültige positive Zahl ein",
|
||||
"successTitle": "Erfolg",
|
||||
|
||||
@@ -287,7 +287,8 @@
|
||||
"disablePasswordLabel": "Disable password",
|
||||
"cancelButton": "Cancel",
|
||||
"saveChangesButton": "Save Changes",
|
||||
"createUserButton": "Create User"
|
||||
"createUserButton": "Create User",
|
||||
"deleteAccountButton": "Delete Account"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "Habits",
|
||||
@@ -407,6 +408,7 @@
|
||||
"notEnoughCoinsDescription": "You need {coinsNeeded} more coins to redeem this reward."
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "Added {amount} coins",
|
||||
"invalidAmountTitle": "Invalid amount",
|
||||
"invalidAmountDescription": "Please enter a valid positive number",
|
||||
"successTitle": "Success",
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
"actionUpdate": "actualizar",
|
||||
"actionCreate": "crear",
|
||||
"errorFailedUserAction": "Error al {action} usuario",
|
||||
"toastDemoDeleteDisabled": "La eliminación está deshabilitada en la instancia demo",
|
||||
"toastCannotDeleteSelf": "No puedes eliminar tu propia cuenta",
|
||||
"confirmDeleteUser": "¿Estás seguro de que deseas eliminar al usuario {username}?",
|
||||
"toastUserDeletedTitle": "Usuario eliminado",
|
||||
"toastUserDeletedDescription": "El usuario {username} ha sido eliminado correctamente",
|
||||
"toastDeleteUserFailed": "Error al eliminar el usuario: {error}",
|
||||
"errorTitle": "Error",
|
||||
"errorFileSizeLimit": "El tamaño del archivo debe ser menor a 5MB",
|
||||
"toastAvatarUploadedTitle": "Avatar subido",
|
||||
@@ -287,7 +293,13 @@
|
||||
"disablePasswordLabel": "Desactivar contraseña",
|
||||
"cancelButton": "Cancelar",
|
||||
"saveChangesButton": "Guardar cambios",
|
||||
"createUserButton": "Crear usuario"
|
||||
"createUserButton": "Crear usuario",
|
||||
"deleteAccountButton": "Eliminar cuenta",
|
||||
"deletingButtonText": "Eliminando...",
|
||||
"areYouSure": "¿Estás seguro?",
|
||||
"deleteUserConfirmation": "¿Estás seguro de que deseas eliminar al usuario {username}?",
|
||||
"cancel": "Cancelar",
|
||||
"confirmDeleteButtonText": "Eliminar"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "Hábitos",
|
||||
@@ -411,6 +423,7 @@
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "Se añadieron {amount} monedas",
|
||||
"invalidAmountTitle": "Cantidad inválida",
|
||||
"invalidAmountDescription": "Por favor ingresa un número positivo válido",
|
||||
"successTitle": "Éxito",
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
"actionUpdate": "mise à jour",
|
||||
"actionCreate": "création",
|
||||
"errorFailedUserAction": "Échec de la {action} de l'utilisateur",
|
||||
"toastDemoDeleteDisabled": "La suppression est désactivée dans la version de démonstration",
|
||||
"toastCannotDeleteSelf": "Vous ne pouvez pas supprimer votre propre compte",
|
||||
"confirmDeleteUser": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username}?",
|
||||
"toastUserDeletedTitle": "Utilisateur supprimé",
|
||||
"toastUserDeletedDescription": "L'utilisateur {username} a été supprimé avec succès",
|
||||
"toastDeleteUserFailed": "Échec de la suppression de l'utilisateur : {error}",
|
||||
"errorTitle": "Erreur",
|
||||
"errorFileSizeLimit": "La taille du fichier doit être inférieure à 5MB",
|
||||
"toastAvatarUploadedTitle": "Avatar téléchargé",
|
||||
@@ -287,7 +293,13 @@
|
||||
"disablePasswordLabel": "Désactiver le mot de passe",
|
||||
"cancelButton": "Annuler",
|
||||
"saveChangesButton": "Sauvegarder les modifications",
|
||||
"createUserButton": "Créer un utilisateur"
|
||||
"createUserButton": "Créer un utilisateur",
|
||||
"deleteAccountButton": "Supprimer le compte",
|
||||
"deletingButtonText": "Suppression en cours...",
|
||||
"areYouSure": "Êtes-vous sûr ?",
|
||||
"deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur {username} ?",
|
||||
"cancel": "Annuler",
|
||||
"confirmDeleteButtonText": "Supprimer"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "Habitudes",
|
||||
@@ -411,6 +423,7 @@
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "{amount} pièces ajoutées",
|
||||
"invalidAmountTitle": "Montant invalide",
|
||||
"invalidAmountDescription": "Veuillez entrer un nombre positif valide",
|
||||
"successTitle": "Succès",
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
"actionUpdate": "更新",
|
||||
"actionCreate": "作成",
|
||||
"errorFailedUserAction": "ユーザーの{action}に失敗しました",
|
||||
"toastDemoDeleteDisabled": "デモインスタンスでは削除が無効になっています",
|
||||
"toastCannotDeleteSelf": "自分のアカウントは削除できません",
|
||||
"confirmDeleteUser": "ユーザー {username} を削除してもよろしいですか?",
|
||||
"toastUserDeletedTitle": "ユーザーが削除されました",
|
||||
"toastUserDeletedDescription": "ユーザー {username} は正常に削除されました",
|
||||
"toastDeleteUserFailed": "ユーザーの削除に失敗しました: {error}",
|
||||
"errorTitle": "エラー",
|
||||
"errorFileSizeLimit": "ファイルサイズは5MB以下である必要があります",
|
||||
"toastAvatarUploadedTitle": "アバターをアップロードしました",
|
||||
@@ -287,7 +293,13 @@
|
||||
"disablePasswordLabel": "パスワードを無効化",
|
||||
"cancelButton": "キャンセル",
|
||||
"saveChangesButton": "変更を保存",
|
||||
"createUserButton": "ユーザーを作成"
|
||||
"createUserButton": "ユーザーを作成",
|
||||
"deleteAccountButton": "アカウントを削除",
|
||||
"deletingButtonText": "削除中...",
|
||||
"areYouSure": "本当によろしいですか?",
|
||||
"deleteUserConfirmation": "ユーザー {username} を削除してもよろしいですか?",
|
||||
"cancel": "キャンセル",
|
||||
"confirmDeleteButtonText": "削除"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "習慣",
|
||||
@@ -411,6 +423,7 @@
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "{amount}コインを追加しました",
|
||||
"invalidAmountTitle": "無効な値です",
|
||||
"invalidAmountDescription": "有効な正の数を入力してください",
|
||||
"successTitle": "成功しました",
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
"actionUpdate": "обновить",
|
||||
"actionCreate": "создать",
|
||||
"errorFailedUserAction": "Не удалось {action} пользователя",
|
||||
"toastDemoDeleteDisabled": "Удаление отключено в демо-версии",
|
||||
"toastCannotDeleteSelf": "Вы не можете удалить свою учетную запись",
|
||||
"confirmDeleteUser": "Вы уверены, что хотите удалить пользователя {username}?",
|
||||
"toastUserDeletedTitle": "Пользователь удален",
|
||||
"toastUserDeletedDescription": "Пользователь {username} успешно удален",
|
||||
"toastDeleteUserFailed": "Не удалось удалить пользователя: {error}",
|
||||
"errorTitle": "Ошибка",
|
||||
"errorFileSizeLimit": "Размер файла должен быть менее 5 МБ",
|
||||
"toastAvatarUploadedTitle": "Аватар загружен",
|
||||
@@ -287,7 +293,13 @@
|
||||
"disablePasswordLabel": "Отключить пароль",
|
||||
"cancelButton": "Отмена",
|
||||
"saveChangesButton": "Сохранить изменения",
|
||||
"createUserButton": "Создать пользователя"
|
||||
"createUserButton": "Создать пользователя",
|
||||
"deleteAccountButton": "Удалить аккаунт",
|
||||
"deletingButtonText": "Удаление...",
|
||||
"areYouSure": "Вы уверены?",
|
||||
"deleteUserConfirmation": "Вы уверены, что хотите удалить пользователя {username}?",
|
||||
"cancel": "Отмена",
|
||||
"confirmDeleteButtonText": "Удалить"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "Привычки",
|
||||
@@ -411,6 +423,7 @@
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "Добавлено {amount} монет",
|
||||
"invalidAmountTitle": "Неверная сумма",
|
||||
"invalidAmountDescription": "Пожалуйста, введите положительное число",
|
||||
"successTitle": "Успех",
|
||||
|
||||
@@ -270,6 +270,12 @@
|
||||
"actionUpdate": "更新",
|
||||
"actionCreate": "创建",
|
||||
"errorFailedUserAction": "用户 {action} 失败",
|
||||
"toastDemoDeleteDisabled": "在演示实例中删除已禁用",
|
||||
"toastCannotDeleteSelf": "您不能删除自己的帐户",
|
||||
"confirmDeleteUser": "您确定要删除用户 {username} 吗?",
|
||||
"toastUserDeletedTitle": "用户已删除",
|
||||
"toastUserDeletedDescription": "用户 {username} 已成功删除",
|
||||
"toastDeleteUserFailed": "删除用户失败: {error}",
|
||||
"errorTitle": "错误",
|
||||
"errorFileSizeLimit": "文件大小必须小于 5MB",
|
||||
"toastAvatarUploadedTitle": "头像已上传",
|
||||
@@ -287,7 +293,13 @@
|
||||
"disablePasswordLabel": "禁用密码",
|
||||
"cancelButton": "取消",
|
||||
"saveChangesButton": "保存更改",
|
||||
"createUserButton": "创建用户"
|
||||
"createUserButton": "创建用户",
|
||||
"deleteAccountButton": "删除账户",
|
||||
"deletingButtonText": "正在删除...",
|
||||
"areYouSure": "您确定吗?",
|
||||
"deleteUserConfirmation": "您确定要删除用户 {username} 吗?",
|
||||
"cancel": "取消",
|
||||
"confirmDeleteButtonText": "删除"
|
||||
},
|
||||
"ViewToggle": {
|
||||
"habitsLabel": "习惯",
|
||||
@@ -411,6 +423,7 @@
|
||||
"cancel": "取消"
|
||||
},
|
||||
"useCoins": {
|
||||
"addedCoinsDescription": "已添加 {amount} 个金币",
|
||||
"invalidAmountTitle": "无效金额",
|
||||
"invalidAmountDescription": "请输入有效的正数",
|
||||
"successTitle": "成功",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.15",
|
||||
"version": "0.2.19",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Reference in New Issue
Block a user