diff --git a/Budfile b/Budfile index b0f33ab..70f07e9 100755 --- a/Budfile +++ b/Budfile @@ -101,3 +101,58 @@ run() { build() { npm run build } + +add_changelog() { + # Get current version from package.json + current_version=$(node -p "require('./package.json').version") + + # Ask if this is for a new version + echo "Is this for a new version? (y/n)" + read -r is_new_version + + if [[ "$is_new_version" =~ ^[Yy]$ ]]; then + new_version=$(node -p "require('./package.json').version") + + # Add new version header + sed -i "/^# Changelog/a \\\n## Version $new_version" CHANGELOG.md + fi + + while true; do + # Get change type + echo "What type of change is this? ([A]dded/[C]hanged/[F]ixed/[R]emoved)" + read -r change_type + + case $change_type in + A | a) change_type="Added" ;; + C | c) change_type="Changed" ;; + F | f) change_type="Fixed" ;; + R | r) change_type="Removed" ;; + *) + echo "Invalid change type" + continue + ;; + esac + + # Get change description + echo "Enter description of the change:" + read -r change_desc + + # Add the change to CHANGELOG.md + if grep -q "^### $change_type" CHANGELOG.md; then + # If type exists, append to it + sed -i "/^### $change_type$/a - $change_desc" CHANGELOG.md + else + # If type doesn't exist, create new section + sed -i "/^## Version/ {N; s/\n/\n\n### $change_type\n- $change_desc\n/}" CHANGELOG.md + fi + + echo "Change added successfully!" + + # Ask if user wants to add another change + echo "Add another change? (y/n)" + read -r add_another + [[ "$add_another" =~ ^[Yy]$ ]] || break + done + + echo "Changelog update complete!" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d8d0e..3511ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Version 0.1.11 + +### Added + +- profile button +- settings to update profile image + +### Changed + +- Move settings and about to under profile button + ## Version 0.1.10 ### Fixed diff --git a/app/actions/data.ts b/app/actions/data.ts index 22a24b6..23e0c69 100644 --- a/app/actions/data.ts +++ b/app/actions/data.ts @@ -165,6 +165,41 @@ export async function removeCoins( return newData } +export async function uploadAvatar(formData: FormData) { + const file = formData.get('avatar') as File + if (!file) throw new Error('No file provided') + + if (file.size > 5 * 1024 * 1024) { // 5MB + throw new Error('File size must be less than 5MB') + } + + // Create avatars directory if it doesn't exist + const avatarsDir = path.join(process.cwd(), 'data', 'avatars') + await fs.mkdir(avatarsDir, { recursive: true }) + + // Generate unique filename + const ext = file.name.split('.').pop() + const filename = `${Date.now()}.${ext}` + const filePath = path.join(avatarsDir, filename) + + // Save file + const buffer = await file.arrayBuffer() + await fs.writeFile(filePath, Buffer.from(buffer)) + + // Update settings with new avatar path + const settings = await loadSettings() + const newSettings = { + ...settings, + profile: { + ...settings.profile, + avatarPath: `/data/avatars/${filename}` + } + } + + await saveSettings(newSettings) + return newSettings; +} + export async function getChangelog(): Promise { try { const changelogPath = path.join(process.cwd(), 'CHANGELOG.md') diff --git a/app/api/avatars/[...path]/route.ts b/app/api/avatars/[...path]/route.ts new file mode 100644 index 0000000..1126b4a --- /dev/null +++ b/app/api/avatars/[...path]/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server' +import fs from 'fs/promises' +import path from 'path' + +export async function GET( + request: Request, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + const { path: pathSegments } = await Promise.resolve(params) + const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || [])) + const file = await fs.readFile(filePath) + const ext = path.extname(filePath).slice(1) + + return new NextResponse(file, { + headers: { + 'Content-Type': `image/${ext}`, + }, + }) + } catch (error) { + return NextResponse.json( + { error: 'File not found' }, + { status: 404 } + ) + } +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 886fbb2..b981b4d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -7,7 +7,10 @@ import { DynamicTimeNoSSR } from '@/components/DynamicTimeNoSSR' import { useAtom } from 'jotai' import { settingsAtom } from '@/lib/atoms' import { Settings } from '@/lib/types' -import { saveSettings } from '../actions/data' +import { saveSettings, uploadAvatar } from '../actions/data' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { User } from 'lucide-react' export default function SettingsPage() { const [settings, setSettings] = useAtom(settingsAtom) @@ -21,88 +24,144 @@ export default function SettingsPage() { if (!settings) return null return ( -
-

Settings

- - - UI Settings - - -
-
- -
- Format large numbers (e.g., 1K, 1M, 1B) + <> +
+

Settings

+ + + UI Settings + + +
+
+ +
+ Format large numbers (e.g., 1K, 1M, 1B) +
-
- - updateSettings({ - ...settings, - ui: { ...settings.ui, useNumberFormatting: checked } - }) - } - /> -
- -
-
- -
- Use thousand separators (e.g., 1,000 vs 1000) -
-
- - updateSettings({ - ...settings, - ui: { ...settings.ui, useGrouping: checked } - }) - } - /> -
- - - - - - System Settings - - -
-
- -
- Select your timezone for accurate date tracking -
-
-
- - + />
-
-
-
-
+ +
+
+ +
+ Use thousand separators (e.g., 1,000 vs 1000) +
+
+ + updateSettings({ + ...settings, + ui: { ...settings.ui, useGrouping: checked } + }) + } + /> +
+ + + + + + System Settings + + +
+
+ +
+ Select your timezone for accurate date tracking +
+
+
+ + +
+
+
+
+ + + Profile Settings + + +
+
+ +
+ Customize your profile picture +
+
+
+ + + + + + +
{ + const newSettings = await uploadAvatar(formData) + setSettings(newSettings) + }}> + { + const file = e.target.files?.[0] + if (file) { + if (file.size > 5 * 1024 * 1024) { // 5MB + alert('File size must be less than 5MB') + e.target.value = '' + return + } + const form = e.target.form + if (form) form.requestSubmit() + } + }} + /> + +
+
+
+
+
+
+ ) } diff --git a/components/Header.tsx b/components/Header.tsx index 6f06e7c..90fcf05 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,6 +1,19 @@ -import { Bell, Settings } from 'lucide-react' +'use client' + +import { useState } from 'react' +import { useAtom } from 'jotai' +import { settingsAtom } from '@/lib/atoms' +import { Bell, Menu, Settings, User, Info } from 'lucide-react' import { Button } from '@/components/ui/button' import { Logo } from '@/components/Logo' +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' interface HeaderProps { @@ -8,27 +21,60 @@ interface HeaderProps { } export default function Header({ className }: HeaderProps) { + const [showAbout, setShowAbout] = useState(false) + const [settings] = useAtom(settingsAtom) return ( -
-
-
- -
- {/* */} - - + <> +
+
+
+ + +
+ + + + + + + + + + Settings + + + + + + + +
-
-
+ + setShowAbout(false)} /> + ) } diff --git a/components/Navigation.tsx b/components/Navigation.tsx index b20c7e9..c22e2c6 100644 --- a/components/Navigation.tsx +++ b/components/Navigation.tsx @@ -13,7 +13,6 @@ const navItems = [ { icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' }, { icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' }, { icon: Coins, label: 'Coins', href: '/coins', position: 'main' }, - { icon: Info, label: 'About', href: '#', position: 'bottom', onClick: (setShow: (show: boolean) => void) => setShow(true) }, ] interface NavigationProps { @@ -46,27 +45,16 @@ export default function Navigation({ className, viewPort }: NavigationProps) {
{/* Add padding at the bottom to prevent content from being hidden */} setShowAbout(false)} /> @@ -92,15 +80,6 @@ export default function Navigation({ className, viewPort }: NavigationProps) { ))} -
- -
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..082639f --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/lib/types.ts b/lib/types.ts index a1746b8..14f5cfe 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -61,7 +61,8 @@ export const getDefaultSettings = (): Settings => ({ }, system: { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone - } + }, + profile: {} }); // Map of data types to their default values @@ -84,9 +85,14 @@ export interface SystemSettings { timezone: string; } +export interface ProfileSettings { + avatarPath?: string; +} + export interface Settings { ui: UISettings; system: SystemSettings; + profile: ProfileSettings; } export interface JotaiHydrateInitialValues { diff --git a/package-lock.json b/package-lock.json index 2e9c224..3a10b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "habittrove", - "version": "0.1.8", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habittrove", - "version": "0.1.8", + "version": "0.1.10", "dependencies": { "@next/font": "^14.2.15", + "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-select": "^2.1.4", @@ -936,6 +938,31 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz", + "integrity": "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", @@ -1064,6 +1091,34 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.4.tgz", + "integrity": "sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.4", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1141,6 +1196,45 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.4.tgz", + "integrity": "sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.1", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-roving-focus": "1.1.1", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.6.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", @@ -1263,6 +1357,36 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz", + "integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz", diff --git a/package.json b/package.json index 6d6f156..f45fc82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.1.10", + "version": "0.1.11", "private": true, "scripts": { "dev": "next dev --turbopack", @@ -16,7 +16,9 @@ }, "dependencies": { "@next/font": "^14.2.15", + "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-select": "^2.1.4",