mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6934432fb5
|
94
.github/workflows/docker-publish.yml
vendored
Normal file
94
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- github-actions
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
EXISTS: ${{ steps.check-version.outputs.EXISTS }}
|
||||
VERSION: ${{ steps.package-version.outputs.VERSION }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Get version from package.json
|
||||
id: package-version
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Check if version exists
|
||||
id: check-version
|
||||
run: |
|
||||
if docker pull dohsimpson/habittrove:v${{ steps.package-version.outputs.VERSION }} 2>/dev/null; then
|
||||
echo "EXISTS=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "EXISTS=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.check-version.outputs.EXISTS == 'false' && format('dohsimpson/habittrove:v{0}', steps.package-version.outputs.VERSION) || '' }}
|
||||
dohsimpson/habittrove:demo
|
||||
|
||||
deploy-demo:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
# demo tracks the demo tag
|
||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||
with:
|
||||
args: rollout restart -n ${{ secrets.KUBE_NAMESPACE }} deploy/${{ secrets.KUBE_DEPLOYMENT }}
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: needs.build-and-push.outputs.EXISTS == 'false'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
VERSION: ${{ needs.build-and-push.outputs.VERSION }}
|
||||
run: |
|
||||
# Extract release notes from CHANGELOG.md
|
||||
notes=$(awk -v version="$VERSION" '
|
||||
$0 ~ "## Version " version {flag=1;next}
|
||||
$0 ~ "## Version " && flag {exit}
|
||||
flag' CHANGELOG.md)
|
||||
|
||||
gh release create "v$VERSION" \
|
||||
--repo="$GITHUB_REPOSITORY" \
|
||||
--title="v$VERSION" \
|
||||
--notes="$notes"
|
||||
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
@@ -1,40 +0,0 @@
|
||||
name: Create and publish a Docker image to Github Container Registry
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
create-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
28
.github/workflows/test.yml
vendored
Normal file
28
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Run lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun test
|
||||
@@ -22,8 +22,11 @@ Want to try HabitTrove before installing? Visit the public [demo instance](https
|
||||
## Usage
|
||||
|
||||
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
|
||||
|
||||
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
|
||||
|
||||
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
|
||||
|
||||
4. **Statistics**: View your progress through the heatmap and streak counters.
|
||||
|
||||
## Docker Deployment
|
||||
@@ -60,7 +63,7 @@ docker run -d \
|
||||
-v ./data:/app/data \
|
||||
-v ./backups:/app/backups \ # Add this line to map the backups directory
|
||||
-e AUTH_SECRET=$AUTH_SECRET \
|
||||
ghcr.io/manindark/habittrove
|
||||
dohsimpson/habittrove
|
||||
```
|
||||
|
||||
Available image tags:
|
||||
@@ -107,7 +110,7 @@ To contribute to HabitTrove, you'll need to set up a development environment. He
|
||||
1. Clone the repository and navigate to the project directory:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ManInDark/HabitTrove.git
|
||||
git clone https://github.com/dohsimpson/habittrove.git
|
||||
cd habittrove
|
||||
```
|
||||
|
||||
@@ -160,7 +163,7 @@ Run these commands regularly during development to catch issues early.
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome feature requests and bug reports! Please [open an issue](https://github.com/ManInDark/habittrove/issues/new).
|
||||
We welcome feature requests and bug reports! Please [open an issue](https://github.com/dohsimpson/habittrove/issues/new). We do not accept pull request at the moment.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import HabitCalendar from '@/components/HabitCalendar'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
{/* <ViewToggle /> */}
|
||||
</div>
|
||||
<HabitCalendar />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,11 @@ import HabitList from '@/components/HabitList'
|
||||
|
||||
export default function HabitsPage() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<HabitList isTasksView={false} />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
{/* <ViewToggle /> */}
|
||||
</div>
|
||||
<HabitList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import HabitList from '@/components/HabitList'
|
||||
|
||||
export default function TasksPage() {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<HabitList isTasksView={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,13 +56,11 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {
|
||||
>
|
||||
@dohsimpson
|
||||
</a>
|
||||
<br/>
|
||||
Fork by <a href="https://github.com/ManInDark" target="_blank" rel="noopener noreferrer" className="font-medium hover:underline">@ManInDark</a>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href="https://github.com/ManInDark/HabitTrove"
|
||||
href="https://github.com/dohsimpson/habittrove"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -43,6 +43,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
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 [selectedUserIds, setSelectedUserIds] = useState<string[]>((habit?.userIds || []).filter(id => id !== currentUser?.id))
|
||||
const [usersData] = useAtom(usersAtom)
|
||||
const users = usersData.users
|
||||
@@ -84,8 +85,6 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
})
|
||||
}
|
||||
|
||||
const { result, message: errorMessage } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
@@ -195,9 +194,24 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
</div>
|
||||
{/* rrule input (habit) */}
|
||||
<div className="col-start-2 col-span-3 text-sm">
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{errorMessage ? errorMessage : convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })}
|
||||
</span>
|
||||
{(() => {
|
||||
let displayText = '';
|
||||
let errorMessage: string | null = null;
|
||||
const { result, message } = convertHumanReadableFrequencyToMachineReadable({ text: ruleText, timezone: settings.system.timezone, isRecurring: isRecurRule });
|
||||
errorMessage = message;
|
||||
displayText = convertMachineReadableFrequencyToHumanReadable({ frequency: result, isRecurRule, timezone: settings.system.timezone })
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={errorMessage ? 'text-destructive' : 'text-muted-foreground'}>
|
||||
{displayText}
|
||||
</span>
|
||||
{errorMessage && (
|
||||
<p className="text-destructive text-xs mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
@@ -315,7 +329,7 @@ export default function AddEditHabitModal({ onClose, onSave, habit, isTask }: Ad
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={errorMessage !== null}>{habit ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</Button>
|
||||
<Button type="submit">{habit ? 'Save Changes' : `Add ${isTask ? 'Task' : 'Habit'}`}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
0
components/CoinBalance.test.tsx
Normal file
0
components/CoinBalance.test.tsx
Normal file
@@ -43,7 +43,6 @@ export default function CoinsManager() {
|
||||
const highlightId = searchParams.get('highlight')
|
||||
const userIdFromQuery = searchParams.get('user') // Get user ID from query
|
||||
const transactionRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const PAGE_ENTRY_COUNTS = [10, 50, 100, 500];
|
||||
|
||||
// Effect to set selected user from query param if admin
|
||||
useEffect(() => {
|
||||
@@ -238,7 +237,9 @@ export default function CoinsManager() {
|
||||
setCurrentPage(1) // Reset to first page when changing page size
|
||||
}}
|
||||
>
|
||||
{PAGE_ENTRY_COUNTS.map(n => <option key={n} value={n}>{n}</option>)}
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={500}>500</option>
|
||||
</select>
|
||||
<span className="text-sm text-muted-foreground">entries</span>
|
||||
</div>
|
||||
@@ -274,7 +275,6 @@ export default function CoinsManager() {
|
||||
}
|
||||
|
||||
const isHighlighted = transaction.id === highlightId;
|
||||
const transactionUser = usersData.users.find(u => u.id === transaction.userId);
|
||||
return (
|
||||
<div
|
||||
key={transaction.id}
|
||||
@@ -304,12 +304,12 @@ export default function CoinsManager() {
|
||||
{transaction.userId && currentUser?.isAdmin && (
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage
|
||||
src={transactionUser?.avatarPath ?
|
||||
`/api/avatars/${transactionUser?.avatarPath?.split('/').pop()}` : undefined}
|
||||
alt={transactionUser?.username}
|
||||
src={usersData.users.find(u => u.id === transaction.userId)?.avatarPath ?
|
||||
`/api/avatars/${usersData.users.find(u => u.id === transaction.userId)?.avatarPath?.split('/').pop()}` : undefined}
|
||||
alt={usersData.users.find(u => u.id === transaction.userId)?.username}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{transactionUser?.username?.[0] || '?'}
|
||||
{usersData.users.find(u => u.id === transaction.userId)?.username?.[0] || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
@@ -220,6 +220,12 @@ const ItemSection = ({
|
||||
<Link
|
||||
href={`/habits?highlight=${habit.id}`}
|
||||
className="flex items-center gap-1 hover:text-primary transition-colors"
|
||||
onClick={() => {
|
||||
const newViewType = isTask ? 'tasks' : 'habits';
|
||||
if (browserSettings.viewType !== newViewType) {
|
||||
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isTask && isTaskOverdue(habit, settings.system.timezone) && !isCompleted && (
|
||||
<TooltipProvider>
|
||||
@@ -308,6 +314,12 @@ const ItemSection = ({
|
||||
<Link
|
||||
href={viewLink}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
onClick={() => {
|
||||
const newViewType = isTask ? 'tasks' : 'habits';
|
||||
if (browserSettings.viewType !== newViewType) {
|
||||
setBrowserSettings(prev => ({ ...prev, viewType: newViewType }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
View
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
|
||||
@@ -40,8 +40,8 @@ export default function HabitCalendar() {
|
||||
}, [completedHabitsMap, settings.system.timezone])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<h1 className="text-2xl font-semibold mb-6">Habit Calendar</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { browserSettingsAtom, settingsAtom, usersAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { Habit, User } from '@/lib/types'
|
||||
import { convertMachineReadableFrequencyToHumanReadable, getCompletionsForToday, isTaskOverdue } from '@/lib/utils'
|
||||
@@ -15,7 +15,6 @@ import { Check, Coins, Edit, MoreVertical, Pin, Undo2 } from 'lucide-react'; //
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HabitContextMenuItems } from './HabitContextMenuItems'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
interface HabitItemProps {
|
||||
habit: Habit
|
||||
@@ -54,7 +53,9 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
const { currentUser, hasPermission } = useHelpers()
|
||||
const canWrite = hasPermission('habit', 'write')
|
||||
const canInteract = hasPermission('habit', 'interact')
|
||||
const pathname = usePathname();
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const isRecurRule = !isTasksView
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -82,7 +83,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
>
|
||||
<CardHeader className="flex-none">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${pathname.includes("tasks") ? 'w-full' : ''} justify-between`}>
|
||||
<CardTitle className={`line-clamp-1 ${habit.archived ? 'text-gray-400 dark:text-gray-500' : ''} flex items-center ${isTasksView ? 'w-full' : ''} justify-between`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{habit.pinned && (
|
||||
<Pin className="h-4 w-4 text-yellow-500" />
|
||||
@@ -107,7 +108,7 @@ export default function HabitItem({ habit, onEdit, onDelete }: HabitItemProps) {
|
||||
<p className={`text-sm ${habit.archived ? 'text-gray-400 dark:text-gray-500' : 'text-gray-500'}`}>
|
||||
When: {convertMachineReadableFrequencyToHumanReadable({
|
||||
frequency: habit.frequency,
|
||||
isRecurRule: pathname.includes("habits"),
|
||||
isRecurRule,
|
||||
timezone: settings.system.timezone
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Input } from '@/components/ui/input'; // Added
|
||||
import { Label } from '@/components/ui/label'; // Added
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; // Added
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { habitsAtom } from '@/lib/atoms'
|
||||
import { browserSettingsAtom, habitsAtom } from '@/lib/atoms'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { Habit } from '@/lib/types'
|
||||
import { getHabitFreq } from '@/lib/utils'; // Added
|
||||
@@ -19,9 +19,12 @@ import EmptyState from './EmptyState'
|
||||
import HabitItem from './HabitItem'
|
||||
import { ViewToggle } from './ViewToggle'
|
||||
|
||||
export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
export default function HabitList() {
|
||||
const { saveHabit, deleteHabit } = useHabits()
|
||||
const [habitsData] = useAtom(habitsAtom) // setHabitsData removed as it's not used
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
// const [settings] = useAtom(settingsAtom); // settingsAtom is not directly used in HabitList itself.
|
||||
|
||||
type SortableField = 'name' | 'coinReward' | 'dueDate' | 'frequency';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
@@ -121,13 +124,21 @@ export default function HabitList({ isTasksView }: { isTasksView: boolean}) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">{`My ${isTasksView ? "Tasks" : "Habits"}`}</h1>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isTasksView ? 'My Tasks' : 'My Habits'}
|
||||
</h1>
|
||||
<span>
|
||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: isTasksView })}>
|
||||
<Plus className='mr-2 h-4 w-4' />{`Add ${isTasksView ? "Task" : "Habit"}`}
|
||||
<Button className="mr-2" onClick={() => setModalConfig({ isOpen: true, isTask: true })}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {'Add Task'}
|
||||
</Button>
|
||||
<Button onClick={() => setModalConfig({ isOpen: true, isTask: false })}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {'Add Habit'}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<div className='py-4'>
|
||||
<ViewToggle />
|
||||
</div>
|
||||
|
||||
{/* Search and Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 my-4">
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { browserSettingsAtom } from '@/lib/atoms'
|
||||
import { useHelpers } from '@/lib/client-helpers'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Calendar, Coins, Gift, Home } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import AboutModal from './AboutModal'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
type ViewPort = 'main' | 'mobile'
|
||||
|
||||
const navItems = () => [
|
||||
const navItems = (isTasksView: boolean) => [
|
||||
{ icon: Home, label: 'Dashboard', href: '/', position: 'main' },
|
||||
{ icon: HabitIcon, label: 'Habits', href: '/habits', position: 'main' },
|
||||
{ icon: TaskIcon, label: 'Tasks', href: '/tasks', position: 'main' },
|
||||
{
|
||||
icon: isTasksView ? TaskIcon : HabitIcon,
|
||||
label: isTasksView ? 'Tasks' : 'Habits',
|
||||
href: '/habits',
|
||||
position: 'main'
|
||||
},
|
||||
{ icon: Calendar, label: 'Calendar', href: '/calendar', position: 'main' },
|
||||
{ icon: Gift, label: 'Wishlist', href: '/wishlist', position: 'main' },
|
||||
{ icon: Coins, label: 'Coins', href: '/coins', position: 'main' },
|
||||
]
|
||||
|
||||
interface NavigationProps {
|
||||
className?: string
|
||||
viewPort: ViewPort
|
||||
}
|
||||
|
||||
export default function Navigation({ viewPort }: NavigationProps) {
|
||||
export default function Navigation({ className, viewPort }: NavigationProps) {
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [isMobileView, setIsMobileView] = useState(false)
|
||||
const [browserSettings] = useAtom(browserSettingsAtom)
|
||||
const isTasksView = browserSettings.viewType === 'tasks'
|
||||
const { isIOS } = useHelpers()
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -49,16 +56,12 @@ export default function Navigation({ viewPort }: NavigationProps) {
|
||||
<>
|
||||
<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-6 w-full">
|
||||
{...navItems().map((item) => (
|
||||
<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 py-2 hover:text-blue-600 dark:hover:text-blue-300 " +
|
||||
(pathname === (item.href) ?
|
||||
"text-blue-500 dark:text-blue-500" :
|
||||
"text-gray-300 dark:text-gray-300")
|
||||
}
|
||||
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>
|
||||
@@ -78,16 +81,13 @@ export default function Navigation({ viewPort }: NavigationProps) {
|
||||
<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().filter(item => item.position === 'main').map((item) => (
|
||||
{navItems(isTasksView).filter(item => item.position === 'main').map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={"flex items-center px-2 py-2 font-medium rounded-md " +
|
||||
(pathname === (item.href) ?
|
||||
"text-blue-500 hover:text-blue-600 hover:bg-gray-700" :
|
||||
"text-gray-300 hover:text-white hover:bg-gray-700")}
|
||||
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" aria-hidden="true" />
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -2,26 +2,30 @@
|
||||
|
||||
import { browserSettingsAtom, habitsAtom, settingsAtom } from '@/lib/atoms'
|
||||
import { HabitIcon, TaskIcon } from '@/lib/constants'
|
||||
import type { ViewType } from '@/lib/types'
|
||||
import { cn, isHabitDueToday } from '@/lib/utils'
|
||||
import { useAtom } from 'jotai'
|
||||
import { NotificationBadge } from './ui/notification-badge'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
interface ViewToggleProps {
|
||||
defaultView?: ViewType
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ViewToggle({
|
||||
defaultView = 'habits',
|
||||
className
|
||||
}: ViewToggleProps) {
|
||||
const [browserSettings, setBrowserSettings] = useAtom(browserSettingsAtom)
|
||||
const [habits] = useAtom(habitsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const handleViewChange = () => {
|
||||
router.push(pathname.includes("habits") ? "/tasks" : "/habits");
|
||||
const handleViewChange = (checked: boolean) => {
|
||||
const newView = checked ? 'tasks' : 'habits'
|
||||
setBrowserSettings({
|
||||
...browserSettings,
|
||||
viewType: newView,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate due tasks count
|
||||
@@ -33,10 +37,10 @@ export function ViewToggle({
|
||||
<div className={cn('inline-flex rounded-full bg-muted/50 h-8', className)}>
|
||||
<div className="relative flex gap-0.5 rounded-full bg-background p-0.5 h-full">
|
||||
<button
|
||||
onClick={handleViewChange}
|
||||
onClick={() => handleViewChange(false)}
|
||||
className={cn(
|
||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||
pathname.includes('habits') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
browserSettings.viewType === 'habits' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<HabitIcon className="h-4 w-4" />
|
||||
@@ -45,14 +49,14 @@ export function ViewToggle({
|
||||
<NotificationBadge
|
||||
label={dueTasksCount}
|
||||
show={dueTasksCount > 0}
|
||||
variant={pathname.includes('tasks') ? 'secondary' : 'default'}
|
||||
variant={browserSettings.viewType === 'tasks' ? 'secondary' : 'default'}
|
||||
className="shadow-md"
|
||||
>
|
||||
<button
|
||||
onClick={handleViewChange}
|
||||
onClick={() => handleViewChange(true)}
|
||||
className={cn(
|
||||
'relative z-10 rounded-full px-4 py-2 text-sm font-medium transition-colors flex items-center gap-2',
|
||||
pathname.includes('tasks') ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
browserSettings.viewType === 'tasks' ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<TaskIcon className="h-4 w-4" />
|
||||
@@ -62,7 +66,7 @@ export function ViewToggle({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0.5 top-0.5 h-[calc(100%-0.25rem)] rounded-full bg-primary transition-transform',
|
||||
pathname.includes('habits') ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
||||
browserSettings.viewType === 'habits' ? 'w-[calc(50%-0.125rem)]' : 'w-[calc(50%-0.125rem)] translate-x-[calc(100%+0.125rem)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
services:
|
||||
habittrove:
|
||||
image: ghcr.io/manindark/habittrove
|
||||
container_name: habittrove
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- "./data:/app/data"
|
||||
- "./backups:/app/backups"
|
||||
image: dohsimpson/habittrove
|
||||
environment:
|
||||
- AUTH_SECRET=your-secret-key-here # Replace with your actual secret
|
||||
|
||||
@@ -22,16 +22,19 @@ import {
|
||||
getDefaultSettings,
|
||||
getDefaultUsersData,
|
||||
getDefaultWishlistData,
|
||||
Habit
|
||||
Habit,
|
||||
ViewType
|
||||
} from "./types";
|
||||
|
||||
export interface BrowserSettings {
|
||||
viewType: ViewType
|
||||
expandedHabits: boolean
|
||||
expandedTasks: boolean
|
||||
expandedWishlist: boolean
|
||||
}
|
||||
|
||||
export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||
viewType: 'habits',
|
||||
expandedHabits: false,
|
||||
expandedTasks: false,
|
||||
expandedWishlist: false
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// client helpers
|
||||
'use-client'
|
||||
|
||||
import { useAtom } from 'jotai'
|
||||
import { useSession } from "next-auth/react"
|
||||
import { User, UserId } from './types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { usersAtom } from './atoms'
|
||||
import { checkPermission } from './utils'
|
||||
|
||||
@@ -13,7 +14,7 @@ export function useHelpers() {
|
||||
const currentUser = usersData.users.find((u) => u.id === currentUserId)
|
||||
// detect iOS: https://stackoverflow.com/a/9039885
|
||||
function iOS() {
|
||||
return typeof navigator !== "undefined" && ([
|
||||
return [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
'iPod Simulator',
|
||||
@@ -22,7 +23,7 @@ export function useHelpers() {
|
||||
'iPod',
|
||||
].includes(navigator.platform)
|
||||
// iPad on iOS 13 detection
|
||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document))
|
||||
|| (navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -181,6 +181,8 @@ export type CompletionCache = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ViewType = 'habits' | 'tasks'
|
||||
|
||||
export interface JotaiHydrateInitialValues {
|
||||
settings: Settings;
|
||||
coins: CoinsData;
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"js-confetti": "^0.12.0",
|
||||
"linkify": "^0.2.1",
|
||||
"linkify-react": "^4.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "15.2.3",
|
||||
|
||||
Reference in New Issue
Block a user