├── .dockerignore ├── .env.example ├── .github ├── FUNDING.yml ├── renovate.json └── workflows │ └── docker.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── Dockerfile ├── README.md ├── app ├── components │ ├── AddSubscriptionPopover.tsx │ ├── DeleteConfirmationDialog.tsx │ ├── EditSubscriptionModal.tsx │ ├── Header.tsx │ ├── IconFinder.tsx │ ├── SearchBar.tsx │ ├── SubscriptionCard.tsx │ ├── SubscriptionGrid.tsx │ ├── Summary.tsx │ ├── number-ticker.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── border-beam.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── link-preview.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── number-ticker.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── rainbow-button.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── entry.client.tsx ├── entry.server.tsx ├── hooks │ └── use-toast.ts ├── lib │ └── utils.ts ├── root.tsx ├── routes │ ├── _index.tsx │ ├── api.currency-rates.ts │ ├── api.icons.ts │ └── api.storage.$key.ts ├── services │ └── currency.server.ts ├── store │ └── subscriptionStore.ts ├── stores │ └── preferences.ts ├── tailwind.css ├── types │ └── currencies.d.ts └── utils │ └── query.client.ts ├── biome.json ├── bun.lockb ├── components.json ├── data └── .gitkeep ├── lefthook.yml ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── logo-dark.png └── logo-light.png ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .git 4 | .env 5 | README.md -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | USE_LOCAL_STORAGE=true -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: ajnart 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "commitMessagePrefix": "⬆️", 7 | "dependencyDashboard": true, 8 | "prCreation": "approval", 9 | "lockFileMaintenance": { 10 | "automerge": false 11 | }, 12 | "minor": { 13 | "automerge": false 14 | }, 15 | "patch": { 16 | "automerge": false 17 | }, 18 | "pin": { 19 | "automerge": false 20 | } 21 | } -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: 7 | - v* 8 | 9 | workflow_dispatch: 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ajnart/subs 14 | 15 | jobs: 16 | build_and_push: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | packages: write 20 | contents: read 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | 25 | - name: Setup Bun 26 | uses: oven-sh/setup-bun@v2 27 | with: 28 | bun-version: latest 29 | 30 | - name: Docker meta 31 | id: meta 32 | uses: docker/metadata-action@v4 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | tags: | 36 | type=raw,value=latest 37 | type=semver,pattern={{version}} 38 | type=semver,pattern={{major}}.{{minor}} 39 | type=semver,pattern={{major}} 40 | type=sha 41 | 42 | - name: Set up QEMU 43 | uses: docker/setup-qemu-action@v2 44 | 45 | - name: Set up Docker Buildx 46 | uses: docker/setup-buildx-action@v2 47 | 48 | - name: Login to GHCR 49 | uses: docker/login-action@v2 50 | with: 51 | registry: ghcr.io 52 | username: ${{ github.repository_owner }} 53 | password: ${{ secrets.GITHUB_TOKEN }} 54 | 55 | - name: Build and push 56 | uses: docker/build-push-action@v4 57 | with: 58 | platforms: linux/amd64,linux/arm64,linux/arm/v7 59 | context: . 60 | push: true 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | cache-from: type=gha 64 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /.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 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | db.sqlite 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | next-env.d.ts 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # local env files 35 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 36 | .env 37 | .env*.local 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | 45 | # idea files 46 | .idea 47 | 48 | # Db file 49 | db.sqlite 50 | 51 | data/config.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug full stack", 6 | "type": "node", 7 | "request": "launch", 8 | "console": "integratedTerminal", 9 | "runtimeExecutable": "bun", 10 | "runtimeArgs": [ 11 | "run", 12 | "dev" 13 | ], 14 | "internalConsoleOptions": "openOnSessionStart", 15 | }, 16 | { 17 | "name": "Attach by Process ID", 18 | "processId": "${command:PickProcess}", 19 | "request": "attach", 20 | "skipFiles": [ 21 | "/**" 22 | ], 23 | "type": "node" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": { 8 | "kind": "build", 9 | "isDefault": true 10 | }, 11 | "problemMatcher": [], 12 | "label": "bun: build", 13 | "detail": "remix vite:build" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:23-alpine 2 | 3 | # Set the working directory inside the container 4 | WORKDIR /app 5 | 6 | COPY package.json ./ 7 | COPY bun.lockb ./ 8 | 9 | COPY . /app/ 10 | 11 | RUN npm install 12 | 13 | ENV USE_LOCAL_STORAGE=false 14 | 15 | VOLUME [ "/app/data" ] 16 | 17 | ENV PORT=7574 18 | 19 | EXPOSE 7574 20 | 21 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subs - Simplistic Open Source Subscription Cost Tracker 2 | 3 | Subs is a lightweight application designed to help you track and manage your subscription costs across different services. With a clean and intuitive interface, it simplifies the process of monitoring your recurring expenses. 4 | 5 | ## Features 6 | 7 | - **Track Subscriptions**: Add, edit, and delete subscription details including name, price, and currency 8 | - **Automatic Favicon Fetching**: Visual identification of your subscriptions with icons from their domains 9 | - **Multi-Currency Support**: Track subscriptions in different currencies with automatic conversion rates 10 | - **Total Cost Calculation**: See your total monthly expenses at a glance 11 | - **Import/Export**: Easily back up your subscription data or move it between devices 12 | - **Client-Side Storage**: Option to store data in your browser for privacy or use SQLite for persistence 13 | - **Responsive Design**: Works seamlessly on both desktop and mobile devices 14 | 15 | ## Demo 16 | 17 | You can try out Subs without installing anything at [subs.ajnart.fr](https://subs.ajnart.fr) 18 | 19 | ![Demo GIF](https://github.com/user-attachments/assets/ffb88333-6c4d-46c9-9ca7-49602106e5f1) 20 | 21 | ## Tech Stack 22 | 23 | Subs is built with modern web technologies: 24 | 25 | - **Framework**: Remix (React) 26 | - **Styling**: Tailwind CSS with Shadcn UI components 27 | - **State Management**: Zustand 28 | - **Package Manager**: Bun 29 | 30 | ## 🚀 Installation 31 | 32 | ### 🌐 Use the Online Version 33 | 34 | Visit [subs.ajnart.fr](https://subs.ajnart.fr) to use the tool immediately without installation. 35 | 36 | ### 🐳 Run with Docker 37 | 38 | Run with a single command: 39 | 40 | ```bash 41 | docker run -p 7574:7574 -v ./data:/app/data --name subs --rm ghcr.io/ajnart/subs 42 | ``` 43 | 44 | Then visit `http://localhost:7574` in your browser. 45 | 46 | ### 📦 Using Docker Compose 47 | 48 | Create a `docker-compose.yaml` file: 49 | 50 | ```yaml 51 | services: 52 | subs: 53 | image: ghcr.io/ajnart/subs 54 | container_name: subs 55 | ports: 56 | - "7574:7574" 57 | restart: unless-stopped 58 | # volumes: Optional: Uncomment to use a volume to save data outside of the default docker volume 59 | # - ./data:/app/data 60 | # environment: 61 | # - USE_LOCAL_STORAGE=true # Uncomment to use browser storage instead of file storage (different config for each browser) 62 | ``` 63 | 64 | Then run: 65 | 66 | ```bash 67 | docker-compose up -d 68 | ``` 69 | 70 | Open [http://localhost:7574](http://localhost:7574) in your browser to see the webui 71 | 72 | > [!NOTE] 73 | > Data is stored in the `/app/data` directory inside the container. Mount this directory as a volume to persist your data between container restarts. 74 | 75 | 76 | 77 | ## Contributing 78 | 79 | Contributions are welcome! Feel free to open issues or submit pull requests to help improve Subs. 80 | 81 | ## License 82 | 83 | This project is open-source and available under the MIT License. 84 | 85 | --- 86 | 87 | Thank you for your interest in Subs! We hope it helps you keep better track of your subscription costs. 88 | -------------------------------------------------------------------------------- /app/components/AddSubscriptionPopover.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { Input } from '@/components/ui/input' 3 | import { Label } from '@/components/ui/label' 4 | import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' 5 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' 6 | import { zodResolver } from '@hookform/resolvers/zod' 7 | import { useLoaderData } from '@remix-run/react' 8 | import { PlusCircle } from 'lucide-react' 9 | import { useEffect, useState } from 'react' 10 | import { useForm } from 'react-hook-form' 11 | import { toast } from 'sonner' 12 | import { z } from 'zod' 13 | import type { loader } from '~/routes/_index' 14 | import type { Subscription } from '~/store/subscriptionStore' 15 | import { IconUrlInput } from './IconFinder' 16 | 17 | interface AddSubscriptionPopoverProps { 18 | addSubscription: (subscription: Omit) => void 19 | } 20 | 21 | const subscriptionSchema = z.object({ 22 | name: z.string().min(1, 'Name is required'), 23 | price: z.number().min(0.01, 'Price must be greater than 0'), 24 | currency: z.string().min(1, 'Currency is required'), 25 | icon: z.string().optional(), 26 | domain: z.string().url('Invalid URL'), 27 | }) 28 | 29 | type SubscriptionFormValues = z.infer 30 | 31 | export const AddSubscriptionPopover: React.FC = ({ addSubscription }) => { 32 | const { rates } = useLoaderData() 33 | const [open, setOpen] = useState(false) 34 | const [shouldFocus, setShouldFocus] = useState(false) 35 | 36 | const { 37 | register, 38 | handleSubmit, 39 | formState: { errors }, 40 | reset, 41 | setFocus, 42 | setValue, 43 | watch, 44 | } = useForm({ 45 | resolver: zodResolver(subscriptionSchema), 46 | defaultValues: { 47 | name: '', 48 | icon: '', 49 | price: 0, 50 | currency: 'USD', 51 | domain: '', 52 | }, 53 | }) 54 | 55 | const iconValue = watch('icon') 56 | 57 | useEffect(() => { 58 | if (shouldFocus) { 59 | setFocus('name') 60 | setShouldFocus(false) 61 | } 62 | }, [shouldFocus, setFocus]) 63 | 64 | const onSubmit = (data: SubscriptionFormValues) => { 65 | addSubscription(data) 66 | toast.success(`${data.name} added successfully.`) 67 | reset() 68 | setShouldFocus(true) 69 | } 70 | 71 | useEffect(() => { 72 | if (open) { 73 | setFocus('name') 74 | } 75 | }, [open, setFocus]) 76 | 77 | return ( 78 | 79 | 80 | 84 | 85 | 86 |
87 |

Add Subscription

88 |
89 |
90 | setValue('icon', value)} 93 | label="Icon (optional)" 94 | error={!!errors.icon} 95 | /> 96 |
97 |
98 | 99 | 100 |

{errors.name?.message}

101 |
102 |
103 |
104 | 105 | 113 |

{errors.price?.message}

114 |
115 |
116 | 117 | 129 |

{errors.currency?.message}

130 |
131 |
132 |
133 | 134 | 135 |

{errors.domain?.message}

136 |
137 |
138 |
139 | 142 |
143 |
144 |
145 |
146 | ) 147 | } 148 | 149 | export default AddSubscriptionPopover 150 | -------------------------------------------------------------------------------- /app/components/DeleteConfirmationDialog.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import { 3 | AlertDialog, 4 | AlertDialogAction, 5 | AlertDialogCancel, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | } from '~/components/ui/alert-dialog' 12 | 13 | interface DeleteConfirmationDialogProps { 14 | isOpen: boolean 15 | onClose: () => void 16 | onConfirm: () => void 17 | subscriptionName: string 18 | } 19 | 20 | const DeleteConfirmationDialog: React.FC = ({ 21 | isOpen, 22 | onClose, 23 | onConfirm, 24 | subscriptionName, 25 | }) => { 26 | return ( 27 | 28 | 29 | 30 | Are you sure? 31 | 32 | This action cannot be undone. This will permanently delete the subscription for {subscriptionName} from your 33 | account. 34 | 35 | 36 | 37 | Cancel 38 | Delete 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default DeleteConfirmationDialog 46 | -------------------------------------------------------------------------------- /app/components/EditSubscriptionModal.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod' 2 | import { useLoaderData } from '@remix-run/react' 3 | import type React from 'react' 4 | import { useEffect } from 'react' 5 | import { Controller, useForm } from 'react-hook-form' 6 | import * as z from 'zod' 7 | import { Button } from '~/components/ui/button' 8 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui/dialog' 9 | import { Input } from '~/components/ui/input' 10 | import { Label } from '~/components/ui/label' 11 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select' 12 | import type { loader } from '~/routes/_index' 13 | import type { Subscription } from '~/store/subscriptionStore' 14 | import { IconUrlInput } from './IconFinder' 15 | import SubscriptionCard from './SubscriptionCard' 16 | 17 | interface EditSubscriptionModalProps { 18 | isOpen: boolean 19 | onClose: () => void 20 | onSave: (subscription: Omit) => void 21 | editingSubscription: Subscription | null 22 | } 23 | 24 | const subscriptionSchema = z.object({ 25 | name: z.string().min(1, 'Name is required'), 26 | price: z.number().min(0.01, 'Price must be greater than 0'), 27 | currency: z.string().min(1, 'Currency is required'), 28 | domain: z.string().url('Invalid URL'), 29 | icon: z.string().optional(), 30 | }) 31 | 32 | const EditSubscriptionModal: React.FC = ({ 33 | isOpen, 34 | onClose, 35 | onSave, 36 | editingSubscription, 37 | }) => { 38 | const { rates } = useLoaderData() 39 | 40 | const { 41 | control, 42 | handleSubmit, 43 | watch, 44 | reset, 45 | setValue, 46 | formState: { errors }, 47 | } = useForm({ 48 | resolver: zodResolver(subscriptionSchema), 49 | defaultValues: { 50 | name: '', 51 | price: 0, 52 | currency: 'USD', 53 | domain: '', 54 | icon: '', 55 | }, 56 | }) 57 | 58 | useEffect(() => { 59 | if (editingSubscription) { 60 | reset(editingSubscription) 61 | } else { 62 | reset({ 63 | name: '', 64 | price: 0, 65 | currency: 'USD', 66 | domain: '', 67 | icon: '', 68 | }) 69 | } 70 | }, [editingSubscription, reset]) 71 | 72 | const watchedFields = watch() 73 | 74 | const previewSubscription: Subscription = { 75 | id: 'preview', 76 | name: watchedFields.name || 'Example Subscription', 77 | price: watchedFields.price || 0, 78 | currency: watchedFields.currency || 'USD', 79 | domain: watchedFields.domain || 'https://example.com', 80 | icon: watchedFields.icon, 81 | } 82 | 83 | const onSubmit = (data: Omit) => { 84 | onSave(data) 85 | onClose() 86 | } 87 | 88 | return ( 89 | 90 | 91 | 92 | {editingSubscription ? 'Edit Subscription' : 'Add Subscription'} 93 | 94 |
95 |
96 |
97 |
98 | setValue('icon', value)} 101 | label="Icon (optional)" 102 | error={!!errors.icon} 103 | /> 104 |
105 |
106 | 107 | } 111 | /> 112 |

{errors.name?.message || '\u00A0'}

113 |
114 |
115 |
116 |
117 | 118 | ( 122 | field.onChange(Number.parseFloat(e.target.value))} 127 | className={errors.price ? 'border-red-500' : ''} 128 | /> 129 | )} 130 | /> 131 |
132 |
133 | 134 | ( 138 | 150 | )} 151 | /> 152 |
153 |
154 |

155 | {errors.price?.message || errors.currency?.message || '\u00A0'} 156 |

157 |
158 |
159 | 160 | ( 164 | 165 | )} 166 | /> 167 |

{errors.domain?.message || '\u00A0'}

168 |
169 |
170 |
171 | {}} onDelete={() => {}} /> 172 |
173 |
174 |
175 | 178 |
179 | 182 |
183 |
184 |
185 |
186 |
187 | ) 188 | } 189 | 190 | export default EditSubscriptionModal 191 | -------------------------------------------------------------------------------- /app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import useSubscriptionStore from '~/store/subscriptionStore' 3 | import AddSubscriptionPopover from './AddSubscriptionPopover' 4 | 5 | // biome-ignore lint/suspicious/noEmptyInterface: 6 | interface HeaderProps {} 7 | 8 | const Header: React.FC = () => { 9 | const { addSubscription } = useSubscriptionStore() 10 | 11 | return ( 12 |
13 |
14 |
15 |

Subs

16 |

Easily track your subscriptions

17 |
18 | 19 |
20 |
21 | ) 22 | } 23 | 24 | export default Header 25 | -------------------------------------------------------------------------------- /app/components/IconFinder.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import { Loader2, Search } from 'lucide-react' 3 | import * as React from 'react' 4 | 5 | import { Button } from '@/components/ui/button' 6 | import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' 7 | import { Input } from '@/components/ui/input' 8 | import { Label } from '@/components/ui/label' 9 | 10 | type Icon = { 11 | value: string 12 | label: string 13 | } 14 | 15 | interface IconUrlInputProps { 16 | value: string 17 | onChange: (value: string) => void 18 | id?: string 19 | label?: string 20 | error?: boolean 21 | placeholder?: string 22 | } 23 | 24 | export function IconUrlInput({ 25 | value, 26 | onChange, 27 | id = 'icon', 28 | label = 'Icon URL', 29 | error = false, 30 | placeholder = 'Enter icon URL or search', 31 | }: IconUrlInputProps) { 32 | const [open, setOpen] = React.useState(false) 33 | const [searchQuery, setSearchQuery] = React.useState('') 34 | const inputRef = React.useRef(null) 35 | 36 | const { data: icons, isLoading } = useQuery({ 37 | queryKey: ['Icons'], 38 | queryFn: async () => { 39 | const response = await fetch('/api/icons') 40 | if (!response.ok) { 41 | throw new Error('Network response was not ok') 42 | } 43 | return response.json() 44 | }, 45 | }) 46 | 47 | const options = React.useMemo( 48 | () => 49 | icons?.icons.map((icon: string) => ({ 50 | label: icon, 51 | value: `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${icon}.png`, 52 | })) || [], 53 | [icons], 54 | ) 55 | 56 | const filteredIcons = React.useMemo( 57 | () => 58 | searchQuery === '' 59 | ? options 60 | : options.filter((icon: Icon) => 61 | icon.label.toLowerCase().replace(/\s+/g, '').includes(searchQuery.toLowerCase().replace(/\s+/g, '')), 62 | ), 63 | [options, searchQuery], 64 | ) 65 | 66 | const handleSelect = (icon: Icon) => { 67 | onChange(icon.value) 68 | setOpen(false) 69 | } 70 | 71 | // Preview the current icon if it's a valid URL 72 | const isValidIconUrl = React.useMemo(() => { 73 | if (!value) return false 74 | try { 75 | return Boolean(new URL(value)) 76 | } catch { 77 | return false 78 | } 79 | }, [value]) 80 | 81 | return ( 82 |
83 | {label && } 84 |
85 |
86 | onChange(e.target.value)} 91 | placeholder={placeholder} 92 | className={error ? 'border-red-500' : ''} 93 | /> 94 |
95 | 96 | 100 | 101 | {isValidIconUrl && ( 102 |
103 | Icon preview { 108 | ;(e.target as HTMLImageElement).src = 109 | 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWltYWdlLW9mZiI+PHBhdGggZD0iTTE4IDExdl0iLz48cGF0aCBkPSJtOS41IDE3LjVMNiAxNCIvPjxwYXRoIGQ9Im0xNCA2LTQuNSA0LjUiLz48Y2lyY2xlIGN4PSI4IiBjeT0iOCIgcj0iMiIvPjxwb2x5Z29uIHBvaW50cz0iMTYgMTEgMTMgMTQgMTYgMTcgMTkgMTQiLz48cGF0aCBkPSM2IDIgSCAxOGM1IDAgNSA4IDAgOCIvPjxwYXRoIGQ9Ik0zIDEzLjJBOC4xIDguMSAwIDAgMCA4IDIyIi8+PHBhdGggZD0iTTIxIDl2OGEyIDIgMCAwIDEtMiAyaC04Ii8+PC9zdmc+' 110 | }} 111 | /> 112 |
113 | )} 114 |
115 | 116 | 117 | 118 | Select Icon 119 | 120 |
121 | 122 | setSearchQuery(e.target.value)} 126 | className="pl-8" 127 | autoFocus 128 | /> 129 |
130 | 131 | {isLoading ? ( 132 |
133 | 134 |
135 | ) : filteredIcons.length === 0 ? ( 136 |
137 | 138 |

No icons found. Try a different search term.

139 |
140 | ) : ( 141 |
142 | {filteredIcons.map((icon: Icon) => ( 143 | 155 | ))} 156 |
157 | )} 158 |
159 |
160 |
161 | ) 162 | } 163 | -------------------------------------------------------------------------------- /app/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Search } from 'lucide-react' 2 | import type React from 'react' 3 | import { Input } from '~/components/ui/input' 4 | 5 | interface SearchBarProps { 6 | onSearch: (query: string) => void 7 | } 8 | 9 | const SearchBar: React.FC = ({ onSearch }) => { 10 | return ( 11 |
12 | 13 | onSearch(e.target.value)} 19 | /> 20 |
21 | ) 22 | } 23 | 24 | export default SearchBar 25 | -------------------------------------------------------------------------------- /app/components/SubscriptionCard.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion' 2 | import { Edit, Trash2 } from 'lucide-react' 3 | import type React from 'react' 4 | import { Button } from '~/components/ui/button' 5 | import { Card, CardContent } from '~/components/ui/card' 6 | import { LinkPreview } from '~/components/ui/link-preview' 7 | import type { Subscription } from '~/store/subscriptionStore' 8 | 9 | interface SubscriptionCardProps { 10 | subscription: Subscription 11 | onEdit: (id: string) => void 12 | onDelete: (id: string) => void 13 | className?: string 14 | } 15 | 16 | const SubscriptionCard: React.FC = ({ subscription, onEdit, onDelete, className }) => { 17 | const { id, name, price, currency, domain, icon } = subscription 18 | 19 | // Sanitize the domain URL 20 | const sanitizeDomain = (domain: string) => { 21 | try { 22 | return new URL(domain).href 23 | } catch { 24 | return new URL(`https://${domain}`).href 25 | } 26 | } 27 | 28 | const sanitizedDomain = sanitizeDomain(domain) 29 | const defaultLogoUrl = `https://www.google.com/s2/favicons?domain=${sanitizedDomain}&sz=64` 30 | 31 | // Use custom icon if available, otherwise fall back to domain favicon 32 | const logoUrl = icon || defaultLogoUrl 33 | 34 | return ( 35 | 41 | 42 |
43 | 47 | 56 |
57 | 58 | 59 | {`${name} 60 |

61 | {name} 62 |

63 |

{`${currency} ${price}`}

64 |
65 |
66 |
67 |
68 | ) 69 | } 70 | 71 | export default SubscriptionCard 72 | -------------------------------------------------------------------------------- /app/components/SubscriptionGrid.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from 'framer-motion' 2 | import type React from 'react' 3 | import type { Subscription } from '~/store/subscriptionStore' 4 | import SubscriptionCard from './SubscriptionCard' 5 | 6 | interface SubscriptionGridProps { 7 | subscriptions: Subscription[] 8 | onEditSubscription: (id: string) => void 9 | onDeleteSubscription: (id: string) => void 10 | } 11 | 12 | const SubscriptionGrid: React.FC = ({ 13 | subscriptions, 14 | onEditSubscription, 15 | onDeleteSubscription, 16 | }) => { 17 | return ( 18 |
19 | 20 | {subscriptions.map((subscription) => ( 21 | 27 | onEditSubscription(subscription.id)} 30 | onDelete={() => onDeleteSubscription(subscription.id)} 31 | /> 32 | 33 | ))} 34 | 35 | {subscriptions.length === 0 && ( 36 |
37 |

No subscriptions found. Add one to get started!

38 |
39 | )} 40 |
41 | ) 42 | } 43 | 44 | export default SubscriptionGrid 45 | -------------------------------------------------------------------------------- /app/components/Summary.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from '@remix-run/react' 2 | import type React from 'react' 3 | import { NumberTicker } from '~/components/number-ticker' 4 | import { Card, CardContent } from '~/components/ui/card' 5 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select' 6 | import type { loader } from '~/routes/_index' 7 | import { usePreferencesStore } from '~/stores/preferences' 8 | 9 | interface SummaryProps { 10 | totals: { [key: string]: number } 11 | } 12 | 13 | const Summary: React.FC = ({ totals }) => { 14 | const { selectedCurrency, setSelectedCurrency } = usePreferencesStore() 15 | const { lastUpdated, rates } = useLoaderData() 16 | 17 | if (!rates || !lastUpdated) { 18 | return null 19 | } 20 | 21 | const calculateTotal = () => { 22 | const totalUSD = Object.entries(totals).reduce((acc, [currency, amount]) => { 23 | return acc + amount / (rates[currency] || 1) 24 | }, 0) 25 | return totalUSD * (rates[selectedCurrency] || 1) 26 | } 27 | 28 | const convertedTotal = calculateTotal() 29 | 30 | return ( 31 | 32 | 33 |

Summary

34 |
35 | {Object.entries(totals).map(([currency, total]) => ( 36 |
37 | {currency}: 38 |

{total.toFixed(0)}

39 |
40 | ))} 41 |
42 |
43 |
44 |
45 | Total 46 | 47 | Rates for: {new Date(lastUpdated).toLocaleDateString()} 48 | 49 |
50 |
51 | 56 | 68 |
69 |
70 |
71 |
72 |
73 | ) 74 | } 75 | 76 | export default Summary 77 | -------------------------------------------------------------------------------- /app/components/number-ticker.tsx: -------------------------------------------------------------------------------- 1 | import { useInView, useMotionValue, useSpring } from 'framer-motion' 2 | import { useEffect, useRef } from 'react' 3 | 4 | import { cn } from '~/lib/utils' 5 | 6 | export function NumberTicker({ 7 | value, 8 | direction = 'up', 9 | delay = 0, 10 | className, 11 | decimalPlaces = 0, 12 | }: { 13 | value: number 14 | direction?: 'up' | 'down' 15 | className?: string 16 | delay?: number // delay in s 17 | decimalPlaces?: number 18 | }) { 19 | const ref = useRef(null) 20 | const motionValue = useMotionValue(direction === 'down' ? value : 0) 21 | const springValue = useSpring(motionValue, { 22 | damping: 25, 23 | stiffness: 100, 24 | duration: 0.4, 25 | }) 26 | const isInView = useInView(ref, { once: true, margin: '0px' }) 27 | 28 | useEffect(() => { 29 | isInView && 30 | setTimeout(() => { 31 | motionValue.set(direction === 'down' ? 0 : value) 32 | }, delay * 1000) 33 | }, [motionValue, isInView, delay, value, direction]) 34 | 35 | useEffect( 36 | () => 37 | springValue.on('change', (latest) => { 38 | if (ref.current) { 39 | ref.current.textContent = Intl.NumberFormat('en-US', { 40 | minimumFractionDigits: decimalPlaces, 41 | maximumFractionDigits: decimalPlaces, 42 | }).format(Number(latest.toFixed(decimalPlaces))) 43 | } 44 | }), 45 | [springValue, decimalPlaces], 46 | ) 47 | 48 | return ( 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /app/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 3 | import { ChevronDownIcon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const Accordion = AccordionPrimitive.Root 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )) 19 | AccordionItem.displayName = "AccordionItem" 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180", 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )) 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 50 |
{children}
51 |
52 | )) 53 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 54 | 55 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 56 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "~/lib/utils" 5 | import { buttonVariants } from "~/components/ui/button" 6 | 7 | const AlertDialog = AlertDialogPrimitive.Root 8 | 9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 10 | 11 | const AlertDialogPortal = AlertDialogPrimitive.Portal 12 | 13 | const AlertDialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 27 | 28 | const AlertDialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 33 | 34 | 42 | 43 | )) 44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 45 | 46 | const AlertDialogHeader = ({ 47 | className, 48 | ...props 49 | }: React.HTMLAttributes) => ( 50 |
57 | ) 58 | AlertDialogHeader.displayName = "AlertDialogHeader" 59 | 60 | const AlertDialogFooter = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | AlertDialogFooter.displayName = "AlertDialogFooter" 73 | 74 | const AlertDialogTitle = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef 77 | >(({ className, ...props }, ref) => ( 78 | 83 | )) 84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 85 | 86 | const AlertDialogDescription = React.forwardRef< 87 | React.ElementRef, 88 | React.ComponentPropsWithoutRef 89 | >(({ className, ...props }, ref) => ( 90 | 95 | )) 96 | AlertDialogDescription.displayName = 97 | AlertDialogPrimitive.Description.displayName 98 | 99 | const AlertDialogAction = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 110 | 111 | const AlertDialogCancel = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 124 | )) 125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 126 | 127 | export { 128 | AlertDialog, 129 | AlertDialogPortal, 130 | AlertDialogOverlay, 131 | AlertDialogTrigger, 132 | AlertDialogContent, 133 | AlertDialogHeader, 134 | AlertDialogFooter, 135 | AlertDialogTitle, 136 | AlertDialogDescription, 137 | AlertDialogAction, 138 | AlertDialogCancel, 139 | } 140 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "~/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /app/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 2 | 3 | const AspectRatio = AspectRatioPrimitive.Root 4 | 5 | export { AspectRatio } 6 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "~/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /app/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "~/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /app/components/ui/border-beam.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils"; 2 | 3 | interface BorderBeamProps { 4 | className?: string; 5 | size?: number; 6 | duration?: number; 7 | borderWidth?: number; 8 | anchor?: number; 9 | colorFrom?: string; 10 | colorTo?: string; 11 | delay?: number; 12 | } 13 | 14 | export const BorderBeam = ({ 15 | className, 16 | size = 200, 17 | duration = 15, 18 | anchor = 90, 19 | borderWidth = 1.5, 20 | colorFrom = "#ffaa40", 21 | colorTo = "#9c40ff", 22 | delay = 0, 23 | }: BorderBeamProps) => { 24 | return ( 25 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" 3 | import { Slot } from "@radix-ui/react-slot" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>