├── data └── .gitkeep ├── .env.example ├── bun.lockb ├── .dockerignore ├── public ├── favicon.ico ├── logo-dark.png └── logo-light.png ├── postcss.config.js ├── app ├── components │ ├── ui │ │ ├── aspect-ratio.tsx │ │ ├── skeleton.tsx │ │ ├── collapsible.tsx │ │ ├── label.tsx │ │ ├── textarea.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── toaster.tsx │ │ ├── input.tsx │ │ ├── sonner.tsx │ │ ├── slider.tsx │ │ ├── checkbox.tsx │ │ ├── use-toast.ts │ │ ├── tooltip.tsx │ │ ├── switch.tsx │ │ ├── badge.tsx │ │ ├── hover-card.tsx │ │ ├── popover.tsx │ │ ├── toggle.tsx │ │ ├── radio-group.tsx │ │ ├── avatar.tsx │ │ ├── border-beam.tsx │ │ ├── scroll-area.tsx │ │ ├── alert.tsx │ │ ├── rainbow-button.tsx │ │ ├── resizable.tsx │ │ ├── toggle-group.tsx │ │ ├── number-ticker.tsx │ │ ├── button.tsx │ │ ├── tabs.tsx │ │ ├── card.tsx │ │ ├── accordion.tsx │ │ ├── input-otp.tsx │ │ ├── calendar.tsx │ │ ├── breadcrumb.tsx │ │ ├── pagination.tsx │ │ ├── table.tsx │ │ ├── drawer.tsx │ │ ├── dialog.tsx │ │ ├── link-preview.tsx │ │ ├── sheet.tsx │ │ ├── form.tsx │ │ ├── alert-dialog.tsx │ │ ├── toast.tsx │ │ ├── command.tsx │ │ ├── navigation-menu.tsx │ │ ├── select.tsx │ │ ├── carousel.tsx │ │ └── context-menu.tsx │ ├── SearchBar.tsx │ ├── Header.tsx │ ├── DeleteConfirmationDialog.tsx │ ├── number-ticker.tsx │ ├── SubscriptionGrid.tsx │ ├── KeyboardShortcutsDialog.tsx │ ├── SubscriptionCard.tsx │ ├── Summary.tsx │ ├── AddSubscriptionPopover.tsx │ ├── IconFinder.tsx │ └── EditSubscriptionModal.tsx ├── types │ └── currencies.d.ts ├── utils │ └── query.client.ts ├── stores │ └── preferences.ts ├── entry.client.tsx ├── lib │ └── utils.ts ├── routes │ ├── api.currency-rates.ts │ ├── api.icons.ts │ └── api.storage.$key.ts ├── tailwind.css ├── services │ └── currency.server.ts ├── hooks │ ├── useKeyboard.ts │ └── use-toast.ts ├── root.tsx ├── entry.server.tsx └── store │ └── subscriptionStore.ts ├── .vscode ├── tasks.json └── launch.json ├── Dockerfile ├── vite.config.ts ├── .github ├── renovate.json ├── FUNDING.yml └── workflows │ └── docker.yml ├── lefthook.yml ├── components.json ├── tsconfig.json ├── .gitignore ├── tailwind.config.ts ├── README.md ├── package.json └── biome.json /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | USE_LOCAL_STORAGE=true -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajnart/subs/HEAD/bun.lockb -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .git 4 | .env 5 | README.md -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajnart/subs/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajnart/subs/HEAD/public/logo-dark.png -------------------------------------------------------------------------------- /public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajnart/subs/HEAD/public/logo-light.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /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/types/currencies.d.ts: -------------------------------------------------------------------------------- 1 | export interface CurrencyRates { 2 | amount: number 3 | base: string 4 | date: string 5 | rates: Record 6 | } 7 | 8 | export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'CAD' | 'AUD' | 'INR' | 'CNY' | 'SGD' | 'CHF' 9 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /app/utils/query.client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query' 2 | 3 | export const queryClient: QueryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | gcTime: 1000 * 60 * 60 * 24 * 7, // 1 week 7 | staleTime: 1000 * 60 * 60 * 6, // 6 hours 8 | refetchOnWindowFocus: false, 9 | }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /app/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 2 | 3 | const Collapsible = CollapsiblePrimitive.Root 4 | 5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 6 | 7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 8 | 9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 10 | -------------------------------------------------------------------------------- /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 | RUN npm run build 14 | 15 | ENV USE_LOCAL_STORAGE=false 16 | 17 | VOLUME [ "/app/data" ] 18 | 19 | ENV PORT=7574 20 | 21 | EXPOSE 7574 22 | 23 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | remix({ 8 | future: { 9 | v3_fetcherPersist: true, 10 | v3_relativeSplatPath: true, 11 | v3_throwAbortReason: true, 12 | }, 13 | }), 14 | tsconfigPaths(), 15 | ], 16 | }); 17 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | check: 4 | glob: '*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}' 5 | run: pnpm biome check --write --no-errors-on-unmatched --files-ignore-unknown=true {staged_files} 6 | stage_fixed: true 7 | 8 | pre-push: 9 | commands: 10 | check: 11 | glob: '*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}' 12 | run: pnpm biome check --no-errors-on-unmatched --files-ignore-unknown=true {push_files} 13 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/tailwind.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 | } -------------------------------------------------------------------------------- /app/stores/preferences.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { persist } from 'zustand/middleware' 3 | 4 | type PreferencesStore = { 5 | selectedCurrency: string 6 | setSelectedCurrency: (currency: string) => void 7 | } 8 | 9 | export const usePreferencesStore = create()( 10 | persist( 11 | (set) => ({ 12 | selectedCurrency: 'USD', 13 | setSelectedCurrency: (currency) => set({ selectedCurrency: currency }), 14 | }), 15 | { 16 | name: 'preferences-storage', 17 | }, 18 | ), 19 | ) 20 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "moduleResolution": "node", 10 | "resolveJsonModule": true, 11 | "target": "ES2019", 12 | "strict": true, 13 | "allowJs": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": ["./app/*"], 18 | "@/*": ["./app/*"] 19 | }, 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from '@remix-run/react' 8 | import { StrictMode, startTransition } from 'react' 9 | import { hydrateRoot } from 'react-dom/client' 10 | import { TooltipProvider } from './components/ui/tooltip' 11 | 12 | startTransition(() => { 13 | hydrateRoot( 14 | document, 15 | 16 | 17 | 18 | 19 | , 20 | ) 21 | }) 22 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { useEffect, useState } from 'react' 3 | import { twMerge } from 'tailwind-merge' 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)) 7 | } 8 | 9 | export function useMediaQuery(query: string): boolean { 10 | const [matches, setMatches] = useState(false) 11 | 12 | useEffect(() => { 13 | const mediaQuery = window.matchMedia(query) 14 | 15 | setMatches(mediaQuery.matches) 16 | 17 | const handler = (event: MediaQueryListEvent) => setMatches(event.matches) 18 | 19 | mediaQuery.addEventListener('change', handler) 20 | return () => mediaQuery.removeEventListener('change', handler) 21 | }, [query]) 22 | 23 | return matches 24 | } 25 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /app/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "~/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |