From f440379a89bfa23eaee9cffc8d69520cbb250a74 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Wed, 1 Apr 2026 17:06:47 -0400 Subject: [PATCH] deps: add shadcn-vue components, lucide icons, ATM config and composable - Card, Badge, Separator, Button, Skeleton from shadcn-vue - lucide-vue-next for icons - config/atms.ts: typed ATM machine registry (single source of truth) - composables/useNostrAtmStatus.ts: Nostr WebSocket logic extracted Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 + pnpm-lock.yaml | 162 ++++++++++++++++++++- src/components/ui/badge/Badge.vue | 26 ++++ src/components/ui/badge/index.ts | 26 ++++ src/components/ui/button/Button.vue | 31 ++++ src/components/ui/button/index.ts | 38 +++++ src/components/ui/card/Card.vue | 22 +++ src/components/ui/card/CardAction.vue | 17 +++ src/components/ui/card/CardContent.vue | 17 +++ src/components/ui/card/CardDescription.vue | 17 +++ src/components/ui/card/CardFooter.vue | 17 +++ src/components/ui/card/CardHeader.vue | 17 +++ src/components/ui/card/CardTitle.vue | 17 +++ src/components/ui/card/index.ts | 7 + src/components/ui/separator/Separator.vue | 29 ++++ src/components/ui/separator/index.ts | 1 + src/components/ui/skeleton/Skeleton.vue | 17 +++ src/components/ui/skeleton/index.ts | 1 + src/composables/useNostrAtmStatus.ts | 123 ++++++++++++++++ src/config/atms.ts | 19 +++ 20 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 src/components/ui/badge/Badge.vue create mode 100644 src/components/ui/badge/index.ts create mode 100644 src/components/ui/button/Button.vue create mode 100644 src/components/ui/button/index.ts create mode 100644 src/components/ui/card/Card.vue create mode 100644 src/components/ui/card/CardAction.vue create mode 100644 src/components/ui/card/CardContent.vue create mode 100644 src/components/ui/card/CardDescription.vue create mode 100644 src/components/ui/card/CardFooter.vue create mode 100644 src/components/ui/card/CardHeader.vue create mode 100644 src/components/ui/card/CardTitle.vue create mode 100644 src/components/ui/card/index.ts create mode 100644 src/components/ui/separator/Separator.vue create mode 100644 src/components/ui/separator/index.ts create mode 100644 src/components/ui/skeleton/Skeleton.vue create mode 100644 src/components/ui/skeleton/index.ts create mode 100644 src/composables/useNostrAtmStatus.ts create mode 100644 src/config/atms.ts diff --git a/package.json b/package.json index 0e4c0d3..0fff0bc 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ }, "dependencies": { "@tailwindcss/vite": "^4.2.2", + "@vueuse/core": "^14.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-vue-next": "^1.0.0", + "reka-ui": "^2.9.3", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", "vue": "^3.5.30" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c1285..6a92144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)) + '@vueuse/core': + specifier: ^14.2.1 + version: 14.2.1(vue@3.5.31(typescript@5.9.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -20,6 +23,9 @@ importers: lucide-vue-next: specifier: ^1.0.0 version: 1.0.0(vue@3.5.31(typescript@5.9.3)) + reka-ui: + specifier: ^2.9.3 + version: 2.9.3(vue@3.5.31(typescript@5.9.3)) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -80,6 +86,24 @@ packages: '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@floating-ui/vue@1.1.11': + resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + + '@internationalized/date@3.12.0': + resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -206,6 +230,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.2': resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@swc/helpers@0.5.20': + resolution: {integrity: sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==} + '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -300,12 +327,23 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + + '@tanstack/vue-virtual@3.13.23': + resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@vitejs/plugin-vue@6.0.5': resolution: {integrity: sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -365,9 +403,26 @@ packages: vue: optional: true + '@vueuse/core@14.2.1': + resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.2.1': + resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + + '@vueuse/shared@14.2.1': + resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} + peerDependencies: + vue: ^3.5.0 + alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -378,6 +433,9 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + defu@6.1.6: + resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -504,6 +562,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -518,6 +579,11 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + reka-ui@2.9.3: + resolution: {integrity: sha512-C9lCVxsSC7uYD0Nbgik1+14FNndHNprZmf0zGQt0ZDYIt5KxXV3zD0hEqNcfRUsEEJvVmoRsUkJnASBVBeaaUw==} + peerDependencies: + vue: '>= 3.4.0' + rolldown@1.0.0-rc.12: resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -601,6 +667,17 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-tsc@3.2.6: resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==} hasBin: true @@ -646,6 +723,34 @@ snapshots: tslib: 2.8.1 optional: true + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@floating-ui/vue@1.1.11(vue@3.5.31(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/utils': 0.2.11 + vue-demi: 0.14.10(vue@3.5.31(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@internationalized/date@3.12.0': + dependencies: + '@swc/helpers': 0.5.20 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.20 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -728,6 +833,10 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.2': {} + '@swc/helpers@0.5.20': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.2.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -796,6 +905,13 @@ snapshots: tailwindcss: 4.2.2 vite: 8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1) + '@tanstack/virtual-core@3.13.23': {} + + '@tanstack/vue-virtual@3.13.23(vue@3.5.31(typescript@5.9.3))': + dependencies: + '@tanstack/virtual-core': 3.13.23 + vue: 3.5.31(typescript@5.9.3) + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -805,6 +921,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/web-bluetooth@0.0.21': {} + '@vitejs/plugin-vue@6.0.5(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1))(vue@3.5.31(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 @@ -892,8 +1010,25 @@ snapshots: typescript: 5.9.3 vue: 3.5.31(typescript@5.9.3) + '@vueuse/core@14.2.1(vue@3.5.31(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.2.1 + '@vueuse/shared': 14.2.1(vue@3.5.31(typescript@5.9.3)) + vue: 3.5.31(typescript@5.9.3) + + '@vueuse/metadata@14.2.1': {} + + '@vueuse/shared@14.2.1(vue@3.5.31(typescript@5.9.3))': + dependencies: + vue: 3.5.31(typescript@5.9.3) + alien-signals@3.1.2: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -902,6 +1037,8 @@ snapshots: csstype@3.2.3: {} + defu@6.1.6: {} + detect-libc@2.1.2: {} enhanced-resolve@5.20.1: @@ -985,6 +1122,8 @@ snapshots: nanoid@3.3.11: {} + ohash@2.0.11: {} + path-browserify@1.0.1: {} picocolors@1.1.1: {} @@ -997,6 +1136,22 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + reka-ui@2.9.3(vue@3.5.31(typescript@5.9.3)): + dependencies: + '@floating-ui/dom': 1.7.6 + '@floating-ui/vue': 1.1.11(vue@3.5.31(typescript@5.9.3)) + '@internationalized/date': 3.12.0 + '@internationalized/number': 3.6.5 + '@tanstack/vue-virtual': 3.13.23(vue@3.5.31(typescript@5.9.3)) + '@vueuse/core': 14.2.1(vue@3.5.31(typescript@5.9.3)) + '@vueuse/shared': 14.2.1(vue@3.5.31(typescript@5.9.3)) + aria-hidden: 1.2.6 + defu: 6.1.6 + ohash: 2.0.11 + vue: 3.5.31(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + rolldown@1.0.0-rc.12(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1): dependencies: '@oxc-project/types': 0.122.0 @@ -1034,8 +1189,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tw-animate-css@1.4.0: {} @@ -1060,6 +1214,10 @@ snapshots: vscode-uri@3.1.0: {} + vue-demi@0.14.10(vue@3.5.31(typescript@5.9.3)): + dependencies: + vue: 3.5.31(typescript@5.9.3) + vue-tsc@3.2.6(typescript@5.9.3): dependencies: '@volar/typescript': 2.4.28 diff --git a/src/components/ui/badge/Badge.vue b/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..d894dfe --- /dev/null +++ b/src/components/ui/badge/Badge.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/ui/badge/index.ts b/src/components/ui/badge/index.ts new file mode 100644 index 0000000..bbc0dfa --- /dev/null +++ b/src/components/ui/badge/index.ts @@ -0,0 +1,26 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Badge } from "./Badge.vue" + +export const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) +export type BadgeVariants = VariantProps diff --git a/src/components/ui/button/Button.vue b/src/components/ui/button/Button.vue new file mode 100644 index 0000000..3763470 --- /dev/null +++ b/src/components/ui/button/Button.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts new file mode 100644 index 0000000..26e2c55 --- /dev/null +++ b/src/components/ui/button/index.ts @@ -0,0 +1,38 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Button } from "./Button.vue" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2 has-[>svg]:px-3", + "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", + "icon": "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) +export type ButtonVariants = VariantProps diff --git a/src/components/ui/card/Card.vue b/src/components/ui/card/Card.vue new file mode 100644 index 0000000..f5a0707 --- /dev/null +++ b/src/components/ui/card/Card.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/card/CardAction.vue b/src/components/ui/card/CardAction.vue new file mode 100644 index 0000000..c91638b --- /dev/null +++ b/src/components/ui/card/CardAction.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardContent.vue b/src/components/ui/card/CardContent.vue new file mode 100644 index 0000000..dfbc552 --- /dev/null +++ b/src/components/ui/card/CardContent.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardDescription.vue b/src/components/ui/card/CardDescription.vue new file mode 100644 index 0000000..71c1b8d --- /dev/null +++ b/src/components/ui/card/CardDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardFooter.vue b/src/components/ui/card/CardFooter.vue new file mode 100644 index 0000000..9e3739e --- /dev/null +++ b/src/components/ui/card/CardFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardHeader.vue b/src/components/ui/card/CardHeader.vue new file mode 100644 index 0000000..4fe4da4 --- /dev/null +++ b/src/components/ui/card/CardHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardTitle.vue b/src/components/ui/card/CardTitle.vue new file mode 100644 index 0000000..5f479e7 --- /dev/null +++ b/src/components/ui/card/CardTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts new file mode 100644 index 0000000..1627758 --- /dev/null +++ b/src/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from "./Card.vue" +export { default as CardAction } from "./CardAction.vue" +export { default as CardContent } from "./CardContent.vue" +export { default as CardDescription } from "./CardDescription.vue" +export { default as CardFooter } from "./CardFooter.vue" +export { default as CardHeader } from "./CardHeader.vue" +export { default as CardTitle } from "./CardTitle.vue" diff --git a/src/components/ui/separator/Separator.vue b/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..78d60ec --- /dev/null +++ b/src/components/ui/separator/Separator.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/separator/index.ts b/src/components/ui/separator/index.ts new file mode 100644 index 0000000..4407287 --- /dev/null +++ b/src/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from "./Separator.vue" diff --git a/src/components/ui/skeleton/Skeleton.vue b/src/components/ui/skeleton/Skeleton.vue new file mode 100644 index 0000000..0dadcef --- /dev/null +++ b/src/components/ui/skeleton/Skeleton.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/skeleton/index.ts b/src/components/ui/skeleton/index.ts new file mode 100644 index 0000000..e5ce72c --- /dev/null +++ b/src/components/ui/skeleton/index.ts @@ -0,0 +1 @@ +export { default as Skeleton } from "./Skeleton.vue" diff --git a/src/composables/useNostrAtmStatus.ts b/src/composables/useNostrAtmStatus.ts new file mode 100644 index 0000000..d2e93a2 --- /dev/null +++ b/src/composables/useNostrAtmStatus.ts @@ -0,0 +1,123 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import { ATM_MACHINES, RELAY_URL, OFFLINE_THRESHOLD_SECONDS, type AtmConfig } from '@/config/atms' + +export interface AtmState { + config: AtmConfig + online: boolean + maintenance: boolean + cashIn: boolean + cashOut: boolean + cashLevel: 'none' | 'low' | 'good' | 'full' + lastSeen: number +} + +function createInitialState(): Map { + const map = new Map() + for (const config of ATM_MACHINES) { + map.set(config.id, { + config, + online: false, + maintenance: false, + cashIn: false, + cashOut: false, + cashLevel: 'none', + lastSeen: 0, + }) + } + return map +} + +export function useNostrAtmStatus() { + const atms = ref>(createInitialState()) + const loading = ref(true) + const now = ref(Math.floor(Date.now() / 1000)) + + let ws: WebSocket | null = null + let refreshInterval: ReturnType | null = null + + function isOnline(atm: AtmState): boolean { + return atm.lastSeen > 0 && (now.value - atm.lastSeen) < OFFLINE_THRESHOLD_SECONDS + } + + function formatAgo(seconds: number): string { + if (seconds < 60) return 'just now' + if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago' + if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago' + return Math.floor(seconds / 86400) + 'd ago' + } + + function statusText(atm: AtmState): string { + if (atm.maintenance) return 'under service' + if (isOnline(atm)) return formatAgo(now.value - atm.lastSeen) + return 'offline' + } + + function connect() { + ws = new WebSocket(RELAY_URL) + + ws.onopen = () => { + const authors = ATM_MACHINES.map((a) => a.pubkey) + ws!.send(JSON.stringify([ + 'REQ', 'atm-status', + { kinds: [30078], authors, '#d': ['atm-availability'] }, + ])) + } + + ws.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data) + + if (data[0] === 'EOSE') { + loading.value = false + return + } + + if (data[0] !== 'EVENT') return + + const event = data[2] + const content = JSON.parse(event.content) + + for (const [id, atm] of atms.value.entries()) { + if (atm.config.pubkey === event.pubkey) { + atms.value.set(id, { + ...atm, + cashIn: content.cash_in || false, + cashOut: content.cash_out || false, + cashLevel: content.cash_level || 'none', + maintenance: content.maintenance || false, + online: true, + lastSeen: event.created_at, + }) + loading.value = false + break + } + } + } catch { + // ignore parse errors + } + } + + ws.onclose = () => { + setTimeout(connect, 5000) + } + + ws.onerror = () => { + ws?.close() + } + } + + onMounted(() => { + connect() + refreshInterval = setInterval(() => { + now.value = Math.floor(Date.now() / 1000) + }, 30000) + }) + + onUnmounted(() => { + ws?.close() + ws = null + if (refreshInterval) clearInterval(refreshInterval) + }) + + return { atms, loading, isOnline, statusText } +} diff --git a/src/config/atms.ts b/src/config/atms.ts new file mode 100644 index 0000000..cf7cb2c --- /dev/null +++ b/src/config/atms.ts @@ -0,0 +1,19 @@ +export interface AtmConfig { + id: string + pubkey: string + name: string + location: string +} + +export const ATM_MACHINES: AtmConfig[] = [ + { + id: 'douro', + pubkey: 'b22bd8a7759fa32d57f0061935a5af38d8598d11bbb700c6dec0d352bd0b6ade', + name: 'Bitcoinmat', + location: 'Trece Cielos', + }, + // To add a new ATM, add another entry here +] + +export const RELAY_URL = 'wss://strfry.atitlan.io' +export const OFFLINE_THRESHOLD_SECONDS = 10 * 60