├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── TODO.md ├── apps └── offline-nextjs │ ├── .eslintrc.json │ ├── .gitignore │ ├── CONTRIBUTING.md │ ├── README.md │ ├── components.json │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── file.svg │ ├── globe.svg │ ├── next.svg │ ├── vercel.svg │ └── window.svg │ ├── src │ ├── app │ │ ├── add │ │ │ └── page.tsx │ │ ├── archive │ │ │ └── page.tsx │ │ ├── edit │ │ │ └── [id] │ │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── habit │ │ │ └── [id] │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── settings │ │ │ └── page.tsx │ ├── components │ │ ├── calendar │ │ │ ├── day.tsx │ │ │ ├── monthly │ │ │ │ ├── charts.tsx │ │ │ │ ├── overview.tsx │ │ │ │ ├── streaks.tsx │ │ │ │ └── view.tsx │ │ │ └── weekly │ │ │ │ ├── sortable-list.tsx │ │ │ │ └── view.tsx │ │ ├── color-picker.tsx │ │ ├── habit-form.tsx │ │ ├── misc.tsx │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── chart.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── dropdrawer.tsx │ │ │ ├── form.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── popover.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── switch.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ ├── constants.ts │ ├── fonts │ │ ├── Excalifont-Regular.woff2 │ │ ├── GeistMonoVF.woff │ │ ├── GeistVF.woff │ │ ├── Mathlete-Bulky.otf │ │ ├── Neucha-Regular.ttf │ │ ├── Pacifico.woff2 │ │ └── fonts.ts │ ├── hooks │ │ ├── use-media-query.ts │ │ └── use-mobile.tsx │ ├── lib │ │ ├── completion-rate │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── day.ts │ │ ├── streak-ranges │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── streaks │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ └── utils.ts │ ├── providers │ │ ├── habit-provider.tsx │ │ ├── monthly-navigation.tsx │ │ ├── post-hog.tsx │ │ └── weekly-navigation.tsx │ ├── state.ts │ ├── state │ │ └── settings.ts │ └── types.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── biome.json ├── package.json ├── packages ├── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── package.json │ ├── src │ ├── button.tsx │ ├── card.tsx │ └── code.tsx │ ├── tsconfig.json │ ├── tsconfig.lint.json │ └── turbo │ └── generators │ ├── config.ts │ └── templates │ └── component.hbs ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── redoit.gif └── turbo.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/.npmrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ], 7 | "editor.selectionClipboard": false 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redoit! a cute and minimal habit tracker 2 | 3 | ![preview](https://raw.githubusercontent.com/lmssiehdev/redoit/refs/heads/main/redoit.gif) 4 | 5 | try it at : [https:redoit.app](https:redoit.app) 6 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/TODO.md -------------------------------------------------------------------------------- /apps/offline-nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/offline-nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for commiting if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /apps/offline-nextjs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/offline-nextjs/README.md: -------------------------------------------------------------------------------- 1 | # redoit! a cute and minimal habit tracker 2 | 3 | ![preview](https://raw.githubusercontent.com/lmssiehdev/redoit/refs/heads/main/redoit.gif) 4 | 5 | try it at : [https:redoit.app](https:redoit.app) 6 | -------------------------------------------------------------------------------- /apps/offline-nextjs/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/offline-nextjs/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | eslint: { 5 | ignoreDuringBuilds: true, 6 | }, 7 | async redirects() { 8 | return [ 9 | { 10 | source: "/feedback", 11 | destination: "https://tally.so/r/wAbG7B", 12 | statusCode: 302, 13 | }, 14 | ]; 15 | }, 16 | async rewrites() { 17 | return [ 18 | { 19 | source: "/ingest/static/:path*", 20 | destination: "https://us-assets.i.posthog.com/static/:path*", 21 | }, 22 | { 23 | source: "/ingest/:path*", 24 | destination: "https://us.i.posthog.com/:path*", 25 | }, 26 | { 27 | source: "/ingest/decide", 28 | destination: "https://us.i.posthog.com/decide", 29 | }, 30 | ]; 31 | }, 32 | skipTrailingSlashRedirect: true, 33 | }; 34 | 35 | export default nextConfig; 36 | -------------------------------------------------------------------------------- /apps/offline-nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "offline-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "vitest", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "@ctrl/tinycolor": "^4.1.0", 15 | "@dnd-kit/core": "^6.3.1", 16 | "@dnd-kit/modifiers": "^7.0.0", 17 | "@dnd-kit/sortable": "^8.0.0", 18 | "@dnd-kit/utilities": "^3.2.2", 19 | "@hookform/resolvers": "^3.10.0", 20 | "@instantdb/react": "^0.17.33", 21 | "@phosphor-icons/react": "^2.1.10", 22 | "@radix-ui/react-dialog": "^1.1.14", 23 | "@radix-ui/react-dropdown-menu": "^2.1.15", 24 | "@radix-ui/react-icons": "^1.3.2", 25 | "@radix-ui/react-label": "^2.1.7", 26 | "@radix-ui/react-popover": "^1.1.14", 27 | "@radix-ui/react-radio-group": "^1.3.7", 28 | "@radix-ui/react-slot": "^1.2.3", 29 | "@radix-ui/react-switch": "^1.2.5", 30 | "@radix-ui/react-toggle": "^1.1.9", 31 | "@radix-ui/react-toggle-group": "^1.1.10", 32 | "@radix-ui/react-tooltip": "^1.2.7", 33 | "canvas-confetti": "^1.9.3", 34 | "class-variance-authority": "^0.7.1", 35 | "clsx": "^2.1.1", 36 | "dayjs": "^1.11.13", 37 | "framer-motion": "^12.16.0", 38 | "html2canvas": "^1.4.1", 39 | "little-date": "^1.0.0", 40 | "lucide-react": "^0.451.0", 41 | "nanoid": "^5.1.5", 42 | "next": "15.0.1", 43 | "posthog-js": "^1.249.4", 44 | "react": "19.0.0-rc-69d4b800-20241021", 45 | "react-colorful": "^5.6.1", 46 | "react-dom": "19.0.0-rc-69d4b800-20241021", 47 | "react-hook-form": "^7.57.0", 48 | "recharts": "^2.15.3", 49 | "tailwind-merge": "^2.6.0", 50 | "tailwindcss-animate": "^1.0.7", 51 | "use-immer": "^0.10.0", 52 | "use-react-screenshot": "^4.0.0", 53 | "vaul": "^1.1.2", 54 | "zod": "^3.25.55", 55 | "zustand": "5.0.0-rc.2" 56 | }, 57 | "devDependencies": { 58 | "@types/canvas-confetti": "^1.9.0", 59 | "@types/node": "^20.19.0", 60 | "@types/react": "^18.3.23", 61 | "@types/react-dom": "^18.3.7", 62 | "eslint": "^8.57.1", 63 | "eslint-config-next": "15.0.1", 64 | "postcss": "^8.5.4", 65 | "tailwindcss": "^3.4.17", 66 | "typescript": "^5.8.3", 67 | "vitest": "^2.1.9" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /apps/offline-nextjs/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/add/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HabitForm } from "@/components/habit-form"; 4 | import { GoToMainPageButton } from "@/components/misc"; 5 | import { useHabitsStore } from "@/state"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | export default function AddHabit() { 9 | const addHabit = useHabitsStore((state) => state.addHabit); 10 | const { back } = useRouter(); 11 | 12 | return ( 13 |
14 |
15 | 16 |

Add Habit

17 |
18 | { 20 | addHabit(payload); 21 | back(); 22 | }} 23 | /> 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/archive/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { VerticalView } from "@/components/calendar/weekly/view"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/edit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HabitForm } from "@/components/habit-form"; 4 | import { GoToMainPageButton } from "@/components/misc"; 5 | import { useHabitsStore } from "@/state"; 6 | import { useRouter } from "next/navigation"; 7 | import { use } from "react"; 8 | 9 | type Params = Promise<{ id: string }>; 10 | 11 | export default function Page(props: { params: Params }) { 12 | const params = use(props.params); 13 | const { id: habitId } = params; 14 | const updateHabitData = useHabitsStore((state) => state.updateHabitData); 15 | const habitData = useHabitsStore((state) => state.data[habitId]); 16 | const router = useRouter(); 17 | 18 | return ( 19 |
20 |
21 | 22 |

Edit Habit

23 |
24 | { 27 | router.back(); 28 | updateHabitData({ id: habitData.id, payload }); 29 | }} 30 | /> 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/apps/offline-nextjs/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @layer base { 3 | img { 4 | @apply inline-block; 5 | } 6 | } 7 | @tailwind components; 8 | @tailwind utilities; 9 | 10 | @layer base { 11 | :root { 12 | --background: 57 70% 95%; 13 | --foreground: 0 0% 3.9%; 14 | --card: 0 0% 100%; 15 | --card-foreground: 0 0% 3.9%; 16 | --popover: 0 0% 100%; 17 | --popover-foreground: 0 0% 3.9%; 18 | --primary: 0 0% 9%; 19 | --primary-foreground: 0 0% 98%; 20 | --secondary: 0 0% 96.1%; 21 | --secondary-foreground: 0 0% 9%; 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | --accent: 0 0% 96.1%; 25 | --accent-foreground: 0 0% 9%; 26 | --destructive: 0 84.2% 60.2%; 27 | --destructive-foreground: 0 0% 98%; 28 | --border: 0 0% 89.8%; 29 | --input: 0 0% 89.8%; 30 | --ring: 0 0% 3.9%; 31 | --radius: 0rem; 32 | --chart-1: 12 76% 61%; 33 | --chart-2: 173 58% 39%; 34 | --chart-3: 197 37% 24%; 35 | --chart-4: 43 74% 66%; 36 | --chart-5: 27 87% 67%; 37 | } 38 | 39 | .dark { 40 | --background: 0 0% 3.9%; 41 | --foreground: 0 0% 98%; 42 | --card: 0 0% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | --popover: 0 0% 3.9%; 45 | --popover-foreground: 0 0% 98%; 46 | --primary: 0 0% 98%; 47 | --primary-foreground: 0 0% 9%; 48 | --secondary: 0 0% 14.9%; 49 | --secondary-foreground: 0 0% 98%; 50 | --muted: 0 0% 14.9%; 51 | --muted-foreground: 0 0% 63.9%; 52 | --accent: 0 0% 14.9%; 53 | --accent-foreground: 0 0% 98%; 54 | --destructive: 0 62.8% 30.6%; 55 | --destructive-foreground: 0 0% 98%; 56 | --border: 0 0% 14.9%; 57 | --input: 0 0% 14.9%; 58 | --ring: 0 0% 83.1%; 59 | --chart-1: 220 70% 50%; 60 | --chart-2: 160 60% 45%; 61 | --chart-3: 30 80% 55%; 62 | --chart-4: 280 65% 60%; 63 | --chart-5: 340 75% 55%; 64 | } 65 | } 66 | @layer base { 67 | * { 68 | @apply border-border; 69 | } 70 | body { 71 | @apply bg-background text-foreground; 72 | } 73 | } 74 | 75 | .polkadot { 76 | background-color: rgba(252, 139, 147, 0.3); 77 | background-image: radial-gradient(circle, #fc8b93 20%, transparent 20%), 78 | radial-gradient(circle, #fc8b93 20%, transparent 20%); 79 | background-size: 10px 10px, 10px 10px; 80 | background-position: 0 0, 5px 5px; 81 | } 82 | 83 | .polkadot-svg { 84 | /* background-color: rgba(252, 139, 147, 0.3); 85 | background-image: url("data:image/svg+xml,%3Csvg width='11' height='11' viewBox='0 0 11 11' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='2' cy='2' r='1.4' fill='%23fc8b93'/%3E%3Ccircle cx='7' cy='7' r='1.4' fill='%23fc8b93'/%3E%3C/svg%3E"); */ 86 | background-image: url("data:image/svg+xml,%3Csvg width='11' height='11' viewBox='0 0 11 11' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='11' height='11' fill='%23965784'/%3E%3Ccircle cx='2' cy='2' r='1.4' fill='%23fff'/%3E%3Ccircle cx='7' cy='7' r='1.4' fill='%23fff'/%3E%3C/svg%3E"); 87 | background-size: 10px 10px; 88 | } 89 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/habit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { HabitStats, MonthlyView } from "@/components/calendar/monthly/view"; 4 | import { GoToMainPageButton, HabitColor } from "@/components/misc"; 5 | import { buttonVariants } from "@/components/ui/button"; 6 | import { HabitProvider, useHabit } from "@/providers/habit-provider"; 7 | import { Archive, PencilSimple } from "@phosphor-icons/react"; 8 | import Link from "next/link"; 9 | import { use } from "react"; 10 | 11 | type Params = Promise<{ id: string }>; 12 | 13 | export default function HabitView(props: { params: Params }) { 14 | const params = use(props.params); 15 | const { id: habitId } = params; 16 | return ( 17 |
18 | 19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 |
28 | ); 29 | } 30 | 31 | function HabitInfo() { 32 | const { 33 | habitData: { name, color, isArchived, id: habitId }, 34 | } = useHabit(); 35 | 36 | return ( 37 |
38 | 39 |
40 |
41 | {isArchived && } 42 | 43 |
{name}
44 |
45 | 49 | 50 | 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Button, buttonVariants } from "@/components/ui/button"; 2 | import { excalifont, mathlete, neucha, pacifico } from "@/fonts/fonts"; 3 | import { cn } from "@/lib/utils"; 4 | import { PostHogProviderWrapper } from "@/providers/post-hog"; 5 | import type { Metadata } from "next"; 6 | import Link from "next/link"; 7 | import "./globals.css"; 8 | 9 | export const metadata: Metadata = { 10 | title: "redoit!", 11 | description: "Your radically easy-to-use habit tracker", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 33 |
34 | 78 |
{children}
79 | 90 |
91 | 92 |
93 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { VerticalView } from "@/components/calendar/weekly/view"; 4 | 5 | export default function Home() { 6 | if (typeof window === "undefined") { 7 | return ( 8 |
9 |
{null}
10 |
11 | ); 12 | } 13 | return ( 14 |
15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import { Switch } from "@/components/ui/switch"; 11 | import { useSettingsStore } from "@/state/settings"; 12 | 13 | export default function Settings() { 14 | const { toggleConfetti, confettiEnabled } = useSettingsStore( 15 | (state) => state, 16 | ); 17 | 18 | return ( 19 |
20 |
21 |
22 |

Settings

23 |
24 |
25 | 26 | 27 | Customization 28 | {} 29 | 30 | 31 |
32 | {/*
33 | 39 | 40 |
*/} 41 |
42 | 48 | 53 |
54 |
55 |
56 | {/* 57 | 58 | */} 59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/day.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { normalizeColor } from "@/components/calendar/monthly/overview"; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from "@/components/ui/tooltip"; 9 | import { type Dayjs, dayjs, normalizeDate } from "@/lib/day"; 10 | import { cn } from "@/lib/utils"; 11 | import { useHabit } from "@/providers/habit-provider"; 12 | import { useSettingsStore } from "@/state/settings"; 13 | import { Status } from "@/types"; 14 | 15 | import { 16 | type GlobalOptions as ConfettiGlobalOptions, 17 | type Options as ConfettiOptions, 18 | default as confetti, 19 | } from "canvas-confetti"; 20 | import type { MouseEvent } from "react"; 21 | 22 | interface DayWithToolTipProps { 23 | date: Dayjs | string; 24 | markDate: (date: string) => void; 25 | options?: ConfettiOptions & 26 | ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; 27 | } 28 | 29 | export function DayWithToolTip({ 30 | date, 31 | markDate, 32 | options, 33 | }: DayWithToolTipProps) { 34 | const { 35 | habitData: { isArchived, color, dates, frequency }, 36 | } = useHabit(); 37 | const formatedDate = normalizeDate(date); 38 | const isFuture = dayjs(date).isAfter(new Date()); 39 | const isActive = frequency[dayjs(date).day()]; 40 | const confettiEnabled = useSettingsStore((state) => state.confettiEnabled); 41 | 42 | const tooltipContent = isArchived ? ( 43 | `${dayjs(formatedDate).format("ll")} • archived` 44 | ) : ( 45 |

46 | {dayjs(formatedDate).format("ll")} 47 | {isActive ? ( 48 | <> 49 | {dates[formatedDate] !== undefined 50 | ? ` • ${ 51 | dates[formatedDate] === Status.Skipped ? "skipped" : "completed" 52 | }` 53 | : ""} 54 | 55 | ) : ( 56 | " • not tracked" 57 | )} 58 |

59 | ); 60 | 61 | const { dayCompletedColor, daySkippedColor } = normalizeColor(color); 62 | const backgroundColor = !isActive 63 | ? "rgba(0, 0, 0, 0.1)" 64 | : dates[formatedDate] !== undefined 65 | ? dates[formatedDate] === Status.Completed 66 | ? dayCompletedColor 67 | : daySkippedColor 68 | : ""; 69 | 70 | function handleDayClick(event: MouseEvent) { 71 | if (isFuture) return; 72 | if (dates[formatedDate] === undefined && confettiEnabled) { 73 | playConfetti(event, options); 74 | } 75 | markDate(formatedDate); 76 | } 77 | return ( 78 | 79 | 80 | 81 | 30 | 38 | 39 | 40 | ); 41 | } 42 | 43 | function MonthlyCalendar() { 44 | const { daysInMonth, startOffset, currentMonth, currentYear } = 45 | useMonthContext(); 46 | const markHabit = useHabitsStore((state) => state.markHabit); 47 | const { habitData } = useHabit(); 48 | 49 | return ( 50 | <> 51 |
52 | {DAYS.map((day) => ( 53 |
54 | {day.substring(0, 2)} 55 |
56 | ))} 57 | {Array.from({ length: startOffset }, (_, i) => ( 58 | // biome-ignore lint/suspicious/noArrayIndexKey: 59 |
60 | ))} 61 | {Array.from({ length: daysInMonth }, (_, i) => { 62 | const date: Dayjs = dayjs( 63 | `${currentMonth + 1}-${i + 1}-${currentYear}`, 64 | ); 65 | return ( 66 | // biome-ignore lint/suspicious/noArrayIndexKey: 67 | 68 | { 71 | markHabit({ id: habitData.id, date: normalizeDate(date) }); 72 | }} 73 | /> 74 | 75 | ); 76 | })} 77 |
78 | 79 | ); 80 | } 81 | 82 | export function MonthlyView() { 83 | return ( 84 | 85 |
86 |
87 | 88 |
89 | 90 |
91 |
92 | ); 93 | } 94 | 95 | export function HabitStats() { 96 | const { 97 | habitData: { dates }, 98 | } = useHabit(); 99 | 100 | if (Object.keys(dates).length === 0) { 101 | return ( 102 |
103 | 104 |

No Stats Available

105 |

Start tracking your habits to see your progress!

106 |
107 | ); 108 | } 109 | 110 | return ( 111 | <> 112 |
113 |

114 | OVERVIEW 115 |

116 | 117 |
118 |
119 |

120 | WEEKLY ACTIVITY 121 |

122 | 123 |
124 | {/*
125 |

126 | Streaks 127 |

128 | 129 |
*/} 130 | 131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/weekly/sortable-list.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmssiehdev/redoit/09871a50208486d9aad53a95a55dc5e3b781f56b/apps/offline-nextjs/src/components/calendar/weekly/sortable-list.tsx -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/calendar/weekly/view.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DayWithToolTip } from "@/components/calendar/day"; 4 | import { Button, buttonVariants } from "@/components/ui/button"; 5 | import { DAYS } from "@/constants"; 6 | import { HabitProvider } from "@/providers/habit-provider"; 7 | import { useHabitsStore } from "@/state"; 8 | import { 9 | Archive, 10 | CaretLeft, 11 | CaretRight, 12 | DotsSixVertical, 13 | DotsThreeVertical, 14 | Plus, 15 | StackPlus, 16 | } from "@phosphor-icons/react"; 17 | import dayjs from "dayjs"; 18 | import { formatDateRange } from "little-date"; 19 | import { useEffect } from "react"; 20 | 21 | import { HabitColor, RepeatedHeader } from "@/components/misc"; 22 | import { 23 | DropDrawer, 24 | DropDrawerContent, 25 | DropDrawerItem, 26 | DropDrawerSeparator, 27 | DropDrawerTrigger, 28 | } from "@/components/ui/dropdrawer"; 29 | import { useMediaQuery } from "@/hooks/use-media-query"; 30 | import { useIsMobile } from "@/hooks/use-mobile"; 31 | import { cn } from "@/lib/utils"; 32 | import { 33 | WeeklyDateProvider, 34 | useWeeklyDate, 35 | } from "@/providers/weekly-navigation"; 36 | import { Status } from "@/types"; 37 | import { 38 | DndContext, 39 | type DragEndEvent, 40 | KeyboardSensor, 41 | PointerSensor, 42 | closestCenter, 43 | useSensor, 44 | useSensors, 45 | } from "@dnd-kit/core"; 46 | import { 47 | restrictToVerticalAxis, 48 | restrictToWindowEdges, 49 | } from "@dnd-kit/modifiers"; 50 | import { 51 | type AnimateLayoutChanges, 52 | SortableContext, 53 | defaultAnimateLayoutChanges, 54 | sortableKeyboardCoordinates, 55 | useSortable, 56 | verticalListSortingStrategy, 57 | } from "@dnd-kit/sortable"; 58 | import { CSS } from "@dnd-kit/utilities"; 59 | import Link from "next/link"; 60 | import { usePathname } from "next/navigation"; 61 | 62 | function WeeklyNavigation() { 63 | const { NEXT_DAY, PREV_DAY, dateArray } = useWeeklyDate(); 64 | 65 | return ( 66 |
67 | 75 |
76 | {dateArray.map((dateString) => { 77 | const date = new Date(dateString); 78 | return ( 79 |
83 | 84 |

{DAYS[date.getDay()].substring(0, 2)}

85 |

{date.getDate()}

86 |
87 |
88 | ); 89 | })} 90 |
91 |
92 | {formatDateRange( 93 | dayjs(dateArray[0]).startOf("day").toDate(), 94 | dayjs(dateArray[dateArray.length - 1]) 95 | .startOf("day") 96 | .toDate(), 97 | { 98 | includeTime: false, 99 | }, 100 | )} 101 |
102 | 105 |
106 | ); 107 | } 108 | 109 | export function SortableHabitRow({ habitId }: { habitId: string }) { 110 | const { dateArray } = useWeeklyDate(); 111 | const markHabit = useHabitsStore((state) => state.markHabit); 112 | const deleteHabit = useHabitsStore((state) => state.deleteHabit); 113 | const archiveHabit = useHabitsStore((state) => state.archiveHabit); 114 | const { color, name, isArchived } = useHabitsStore( 115 | (state) => state.data[habitId], 116 | ); 117 | const { isMobile } = useMediaQuery(); 118 | const animateLayoutChanges: AnimateLayoutChanges = (args) => 119 | defaultAnimateLayoutChanges({ ...args, wasDragging: true }); 120 | 121 | const { 122 | attributes, 123 | listeners, 124 | setNodeRef, 125 | transform, 126 | transition, 127 | setActivatorNodeRef, 128 | isDragging, 129 | } = useSortable({ id: habitId, animateLayoutChanges }); 130 | 131 | const style = { 132 | transform: CSS.Transform.toString(transform), 133 | transition, 134 | }; 135 | 136 | useEffect(() => { 137 | if (!isDragging) return; 138 | document.body.style.cursor = "grabbing"; 139 | 140 | return () => { 141 | document.body.style.cursor = ""; 142 | }; 143 | }, [isDragging]); 144 | 145 | const Action = ( 146 | archiveHabit(habitId)} 149 | deleteFn={() => deleteHabit(habitId)} 150 | isArchived={isArchived} 151 | /> 152 | ); 153 | 154 | return ( 155 | <> 156 |
164 |
165 |
166 | {!isArchived && ( 167 | 176 | )} 177 | 178 | 182 | {name} 183 | 184 | {isMobile &&
{Action}
} 185 |
186 | 187 |
188 | {dateArray.map((date) => ( 189 | markHabit({ id: habitId, date })} 193 | /> 194 | ))} 195 |
196 | {!isMobile && Action} 197 |
198 |
199 |
200 | 201 | ); 202 | } 203 | 204 | export function HabitRow({ archived = false }) { 205 | const habitData = useHabitsStore((state) => state.data); 206 | const orderedHabits = useHabitsStore((state) => state.orderedData); 207 | const reorderHabit = useHabitsStore((state) => state.reorderHabit); 208 | const sensors = useSensors( 209 | useSensor(PointerSensor), 210 | useSensor(KeyboardSensor, { 211 | coordinateGetter: sortableKeyboardCoordinates, 212 | }), 213 | ); 214 | 215 | const habitsToRender = orderedHabits.filter( 216 | (habitId) => habitData[habitId].isArchived === archived, 217 | ); 218 | 219 | return ( 220 | <> 221 | 232 | 236 | {habitsToRender.map((id) => ( 237 | 238 | ))} 239 | 240 | 241 | 242 | ); 243 | function handleDragEnd(event: DragEndEvent) { 244 | const { active, over } = event; 245 | const activeId = active.id as string; 246 | const overId = over?.id as string; 247 | 248 | if (activeId !== overId) { 249 | reorderHabit({ activeId, overId }); 250 | } 251 | } 252 | } 253 | 254 | export function HabitRowAction({ 255 | habitId, 256 | archiveHabit, 257 | deleteFn, 258 | isArchived, 259 | }: { 260 | habitId: string; 261 | archiveHabit: () => void; 262 | deleteFn: () => void; 263 | isArchived: boolean; 264 | }) { 265 | const isMobile = useIsMobile(); 266 | return ( 267 | 268 | 269 | 277 | 278 | 279 | 280 | Edit 281 | 282 | 283 | 284 | {isArchived ? "Unarchive" : "Archive"} 285 | 286 | 287 | Delete 288 | 289 | 290 | 291 | ); 292 | } 293 | 294 | function StreakRow() { 295 | const { dateArray } = useWeeklyDate(); 296 | const data = useHabitsStore((state) => state.data); 297 | 298 | function caluculateDateStreak(date: string) { 299 | let count = 0; 300 | for (const { dates } of Object.values(data)) { 301 | if (dates[date] === Status.Completed) { 302 | count++; 303 | } 304 | } 305 | return count; 306 | } 307 | 308 | return ( 309 |
310 | 317 | 318 | Add habit 319 | 320 |
321 | {dateArray.map((date) => ( 322 |
{caluculateDateStreak(date)}
323 | ))} 324 |
325 |
326 |
327 | ); 328 | } 329 | 330 | export function VerticalView() { 331 | const { isMobile } = useMediaQuery(); 332 | const pathname = usePathname(); 333 | const showArchived = pathname === "/archive"; 334 | 335 | const emptyHabits = useHabitsStore( 336 | (state) => 337 | Object.values(state.data).filter( 338 | ({ isArchived }) => isArchived === showArchived, 339 | ).length === 0, 340 | ); 341 | 342 | if (emptyHabits) 343 | return ( 344 |
345 | {showArchived ? ( 346 | <> 347 | 348 |
349 | You don’t have any archived habits yet! 350 |
351 | 352 | ) : ( 353 | <> 354 | 355 |
Your habit list is empty!
356 | 363 | 364 | Add habit 365 | 366 | 367 | )} 368 |
369 | ); 370 | 371 | return ( 372 | <> 373 |
374 |

375 | {showArchived ? ( 376 |
377 | 378 | 379 |
380 | ) : ( 381 | 382 | )} 383 |

384 |
385 | 386 | 387 | 388 | {!isMobile && } 389 | 390 | 391 | ); 392 | } 393 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/color-picker.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, PopoverContent } from "@/components/ui/popover"; 2 | import { colors } from "@/constants"; 3 | import { PopoverTrigger } from "@radix-ui/react-popover"; 4 | 5 | export function ColorPicker({ 6 | value, 7 | onChange, 8 | }: { 9 | value: string; 10 | onChange: (newColor: string) => void; 11 | }) { 12 | return ( 13 | 14 | 15 |
19 | 20 | 21 | {/* */} 22 |
23 | {colors.map((color) => ( 24 |
33 |
34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/habit-form.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import { type Control, useController, useForm } from "react-hook-form"; 3 | import * as z from "zod"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { Input } from "@/components/ui/input"; 15 | 16 | import { ColorPicker } from "@/components/color-picker"; 17 | import { Switch } from "@/components/ui/switch"; 18 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 19 | import { DAYS, DEFAULT_HABIT_COLOR } from "@/constants"; 20 | import type { HabitData } from "@/types"; 21 | import { useMemo } from "react"; 22 | 23 | const HABIT_NAME_MAX_LENGTH = 30; 24 | 25 | function frequencyBooleanToString(payload?: boolean[]) { 26 | if (!payload) return Array.from({ length: 7 }, (_, i) => String(i)); 27 | const frequency: string[] = []; 28 | payload.forEach((isActive, i) => isActive && frequency.push(String(i))); 29 | return frequency; 30 | } 31 | 32 | function frequencyStringToBoolean(payload: string[]) { 33 | const frequency = Array.from({ length: 7 }, () => false); 34 | for (const idx of payload) { 35 | frequency[Number(idx)] = true; 36 | } 37 | return frequency; 38 | } 39 | 40 | const formSchema = z.object({ 41 | name: z 42 | .string() 43 | .min(1, { 44 | message: "Please provide a valid name.", 45 | }) 46 | .transform((val) => val.trim().slice(0, HABIT_NAME_MAX_LENGTH)), 47 | color: z.string(), 48 | archived: z.boolean(), 49 | frequency: z.string().array(), 50 | }); 51 | 52 | export function HabitForm({ 53 | onSubmit, 54 | habitData, 55 | }: { 56 | habitData?: Partial; 57 | onSubmit: ( 58 | payload: Pick, 59 | ) => void; 60 | }) { 61 | const isEditing = useMemo(() => habitData?.name !== undefined, [habitData]); 62 | const form = useForm>({ 63 | resolver: zodResolver(formSchema), 64 | defaultValues: { 65 | name: habitData?.name ?? "", 66 | color: habitData?.color ?? DEFAULT_HABIT_COLOR, 67 | archived: habitData?.isArchived ?? false, 68 | frequency: frequencyBooleanToString(habitData?.frequency), 69 | }, 70 | }); 71 | 72 | function handleFormSubmit(values: z.infer) { 73 | const { name, color, archived, frequency } = values; 74 | 75 | onSubmit({ 76 | name, 77 | color, 78 | isArchived: archived, 79 | frequency: frequencyStringToBoolean(frequency), 80 | }); 81 | // posthog.capture(isEditing ? "habit_update" : "habit_create"); 82 | // form.setValue("name", ""); 83 | } 84 | 85 | return ( 86 |
87 | 88 |
89 | ( 93 | 94 | {/* @HACK: to fix styling */} 95 | . 96 | 97 | 98 | 99 | 100 | 101 | )} 102 | /> 103 | ( 107 | 108 | Name 109 | 110 | 116 | 117 | 118 | 119 | 120 | )} 121 | /> 122 |
123 | 124 | {isEditing && ( 125 | ( 129 | 130 | Archived 131 | 132 |
133 | 137 |
138 |
139 | 140 |
141 | )} 142 | /> 143 | )} 144 | 156 | 157 | 158 | ); 159 | } 160 | 161 | function FrequencyForm({ 162 | control, 163 | }: { 164 | control: Control< 165 | { 166 | name: string; 167 | color: string; 168 | archived: boolean; 169 | frequency: string[]; 170 | }, 171 | // biome-ignore lint/suspicious/noExplicitAny: 172 | any 173 | >; 174 | }) { 175 | const { 176 | field: { value, onChange }, 177 | } = useController({ control, name: "frequency" }); 178 | 179 | const handleWeekDaysClick = () => { 180 | onChange( 181 | frequencyBooleanToString([false, true, true, true, true, true, false]), 182 | ); 183 | }; 184 | 185 | const handleEveryDayClick = () => { 186 | onChange( 187 | frequencyBooleanToString([true, true, true, true, true, true, true]), 188 | ); 189 | }; 190 | 191 | const isWeekDaysSelected = 192 | value.length === 5 && !value.includes("0") && !value.includes("6"); 193 | const isEveryDaySelected = value.length === 7; 194 | 195 | return ( 196 | ( 200 | 201 | Frequency 202 | 203 | 204 | { 207 | if (value.length === 0) return; 208 | field.onChange(value); 209 | }} 210 | className="gap-0" 211 | value={field.value} 212 | > 213 | {DAYS.map((day, index) => ( 214 | 220 | {day.substring(0, 3)} 221 | 222 | ))} 223 | 224 | 225 |
226 | 234 | 242 |
243 | 244 |
245 | )} 246 | /> 247 | ); 248 | } 249 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/misc.tsx: -------------------------------------------------------------------------------- 1 | import { normalizeColor } from "@/components/calendar/monthly/overview"; 2 | import { Button } from "@/components/ui/button"; 3 | import { cn } from "@/lib/utils"; 4 | import { ArrowLeft } from "@phosphor-icons/react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export function GoToMainPageButton() { 8 | const { back } = useRouter(); 9 | 10 | return ( 11 | 14 | ); 15 | } 16 | 17 | export function RepeatedHeader({ 18 | word, 19 | }: { word: "habits" | "archived" | "stats" }) { 20 | // @HACKY: use tailwind safelist 21 | const content = { 22 | habits: "after:content-['habits']", 23 | archived: "after:content-['archived']", 24 | stats: "after:content-['stats']", 25 | }; 26 | return ( 27 | 33 | {word} 34 | 35 | ); 36 | } 37 | 38 | export function HabitColor({ 39 | color, 40 | className, 41 | }: { 42 | color: string; 43 | className?: string; 44 | }) { 45 | const { dayCompletedColor } = normalizeColor(color); 46 | return ( 47 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { type VariantProps, cva } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | xs: "h-6 rounded-sm px-2.5 text-xs", 26 | sm: "h-8 rounded-md px-3 text-xs", 27 | lg: "h-10 rounded-md px-8", 28 | icon: "h-9 w-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | }, 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | }, 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /apps/offline-nextjs/src/components/ui/chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as RechartsPrimitive from "recharts"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | // Format: { THEME_NAME: CSS_SELECTOR } 9 | const THEMES = { light: "", dark: ".dark" } as const; 10 | 11 | export type ChartConfig = { 12 | [k in string]: { 13 | label?: React.ReactNode; 14 | icon?: React.ComponentType; 15 | } & ( 16 | | { color?: string; theme?: never } 17 | | { color?: never; theme: Record } 18 | ); 19 | }; 20 | 21 | type ChartContextProps = { 22 | config: ChartConfig; 23 | }; 24 | 25 | const ChartContext = React.createContext(null); 26 | 27 | function useChart() { 28 | const context = React.useContext(ChartContext); 29 | 30 | if (!context) { 31 | throw new Error("useChart must be used within a "); 32 | } 33 | 34 | return context; 35 | } 36 | 37 | const ChartContainer = React.forwardRef< 38 | HTMLDivElement, 39 | React.ComponentProps<"div"> & { 40 | config: ChartConfig; 41 | children: React.ComponentProps< 42 | typeof RechartsPrimitive.ResponsiveContainer 43 | >["children"]; 44 | } 45 | >(({ id, className, children, config, ...props }, ref) => { 46 | const uniqueId = React.useId(); 47 | const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; 48 | 49 | return ( 50 | 51 |
60 | 61 | 62 | {children} 63 | 64 |
65 |
66 | ); 67 | }); 68 | ChartContainer.displayName = "Chart"; 69 | 70 | const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { 71 | const colorConfig = Object.entries(config).filter( 72 | ([, config]) => config.theme || config.color, 73 | ); 74 | 75 | if (!colorConfig.length) { 76 | return null; 77 | } 78 | 79 | return ( 80 |