diff --git a/package.json b/package.json index a8525f2..df5dc4e 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "lucide-react": "^0.462.0", "ngeohash": "^0.6.3", "nostr-tools": "^2.13.0", + "npubcash-sdk": "^0.2.0", "qrcode": "^1.5.4", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -84,7 +85,7 @@ "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.3", - "zod": "^3.23.8" + "zod": "^3.25.71" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index ea86a74..2da8a41 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -14,7 +14,7 @@ interface AppProviderProps { // Zod schema for RelayMetadata validation const RelayMetadataSchema = z.object({ relays: z.array(z.object({ - url: z.string().url(), + url: z.url(), read: z.boolean(), write: z.boolean(), })), @@ -23,7 +23,7 @@ const RelayMetadataSchema = z.object({ // Zod schema for BlossomServerMetadata validation const BlossomServerMetadataSchema = z.object({ - servers: z.array(z.string().url()), + servers: z.array(z.url()), updatedAt: z.number(), }) satisfies z.ZodType; diff --git a/src/src/App.test.tsx b/src/src/App.test.tsx new file mode 100644 index 0000000..bedec00 --- /dev/null +++ b/src/src/App.test.tsx @@ -0,0 +1,8 @@ +import { render } from '@testing-library/react'; +import { test } from 'vitest'; + +import App from './App'; + +test('App', () => { + render(); +}) \ No newline at end of file diff --git a/src/src/App.tsx b/src/src/App.tsx new file mode 100644 index 0000000..76cc557 --- /dev/null +++ b/src/src/App.tsx @@ -0,0 +1,79 @@ +// NOTE: This file should normally not be modified unless you are adding a new provider. +// To add new routes, edit the AppRouter.tsx file. + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createHead, UnheadProvider } from '@unhead/react/client'; +import { InferSeoMetaPlugin } from '@unhead/addons'; +import { Suspense } from 'react'; +import NostrProvider from '@/components/NostrProvider'; +import { NostrSync } from '@/components/NostrSync'; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { NostrLoginProvider } from '@nostrify/react/login'; +import { AppProvider } from '@/components/AppProvider'; +import { NWCProvider } from '@/contexts/NWCContext'; +import { LanguageProvider } from '@/contexts/LanguageContext'; +import { AppConfig } from '@/contexts/AppContext'; +import { PWAInstallPrompt } from '@/components/PWAInstallPrompt'; +import { AmberCallbackHandler } from '@/components/auth/AmberCallbackHandler'; +import AppRouter from './AppRouter'; + +const head = createHead({ + plugins: [ + InferSeoMetaPlugin(), + ], +}); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 60000, // 1 minute + gcTime: Infinity, + }, + }, +}); + +const defaultConfig: AppConfig = { + theme: "dark", + relayMetadata: { + relays: [ + { url: 'wss://relay.ditto.pub', read: true, write: true }, + { url: 'wss://goskatespots.nostr1.com', read: true, write: true }, + { url: 'wss://wotr.relatr.xyz', read: true, write: true }, + { url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator - read only + ], + updatedAt: 0, + }, + useSkateRelay: true, // Use community relay by default for skate content +}; + +export function App() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/src/src/AppRouter.tsx b/src/src/AppRouter.tsx new file mode 100644 index 0000000..9ffc49a --- /dev/null +++ b/src/src/AppRouter.tsx @@ -0,0 +1,95 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { ScrollToTop } from "./components/ScrollToTop"; + +import Index from "./pages/Index"; +import MapPage from "./pages/MapPage"; +import EventsPage from "./pages/EventsPage"; +import FoodPage from "./pages/FoodPage"; +import WalletPage from "./pages/WalletPage"; +import SettingsPage from "./pages/SettingsPage"; +import CreateSpotPage from "./pages/CreateSpotPage"; +import CreateShopPage from "./pages/CreateShopPage"; +import CreateEventPage from "./pages/CreateEventPage"; +import CreateListingPage from "./pages/CreateListingPage"; +import CreateTourPage from "./pages/CreateTourPage"; +import EditSpotPage from "./pages/EditSpotPage"; +import EditShopPage from "./pages/EditShopPage"; +import EditEventPage from "./pages/EditEventPage"; +import GearSwapPage from "./pages/GearSwapPage"; +import SpotDetailPage from "./pages/SpotDetailPage"; +import ShopDetailPage from "./pages/ShopDetailPage"; +import EventDetailPage from "./pages/EventDetailPage"; +import OnboardingPage from "./pages/OnboardingPage"; +import PyramidPage from "./pages/PyramidPage"; +import LabPage from "./pages/LabPage"; +import BecomeCoachPage from "./pages/BecomeCoachPage"; +import BecomeFilmerPage from "./pages/BecomeFilmerPage"; +import CoachDetailPage from "./pages/CoachDetailPage"; +import TourDetailPage from "./pages/TourDetailPage"; +import AboutPage from "./pages/AboutPage"; +import FAQPage from "./pages/FAQPage"; +import CommunityStatsPage from "./pages/CommunityStatsPage"; +import SpotCollectionsPage from "./pages/SpotCollectionsPage"; +import CollectionDetailPage from "./pages/CollectionDetailPage"; +import DirectMessagesPage from "./pages/DirectMessagesPage"; +import ConversationPage from "./pages/ConversationPage"; +import BountyLeaderboardPage from "./pages/BountyLeaderboardPage"; +import SignersPage from "./pages/SignersPage"; +import StashPage from "./pages/StashPage"; +import { NIP19Page } from "./pages/NIP19Page"; +import NotFound from "./pages/NotFound"; + +export function AppRouter() { + return ( + + + + } /> + } /> + } /> + } /> + {/* Legacy route redirect */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* Bounty System */} + } /> + {/* Security & Signers */} + } /> + {/* Stash - Private Inventory */} + } /> + {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} + } /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + } /> + + + ); +} +export default AppRouter; diff --git a/src/src/components/AddPhotoToSpot.tsx b/src/src/components/AddPhotoToSpot.tsx new file mode 100644 index 0000000..6a3fc70 --- /dev/null +++ b/src/src/components/AddPhotoToSpot.tsx @@ -0,0 +1,234 @@ +import { useState } from 'react'; +import { Camera, Upload, Loader2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { useToast } from '@/hooks/useToast'; +import { useQueryClient } from '@tanstack/react-query'; +import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions'; +import { SKATE_SPOT_KIND } from '@/lib/types'; +import { LoginArea } from '@/components/auth/LoginArea'; + +interface AddPhotoToSpotProps { + spotPubkey: string; + spotIdentifier: string; + spotTitle: string; +} + +export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) { + const { user } = useCurrentUser(); + const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const [open, setOpen] = useState(false); + const [caption, setCaption] = useState(''); + const [uploadedImage, setUploadedImage] = useState<{ url: string; mimeType?: string } | null>(null); + + const spotRef = `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + try { + const file = files[0]; + const tags = await uploadFile(file); + const url = tags.find(([name]) => name === 'url')?.[1]; + const mimeType = tags.find(([name]) => name === 'm')?.[1]; + + if (url) { + setUploadedImage({ url, mimeType }); + toast({ + title: 'Photo uploaded', + description: 'Add a caption and submit to contribute!', + }); + } + } catch (error) { + toast({ + title: 'Upload failed', + description: error instanceof Error ? error.message : 'Failed to upload photo', + variant: 'destructive', + }); + } + + e.target.value = ''; + }; + + const handleSubmit = async () => { + if (!uploadedImage) { + toast({ + title: 'No photo', + description: 'Please upload a photo first.', + variant: 'destructive', + }); + return; + } + + try { + // Create kind 1063 file metadata event + const eventTags: string[][] = [ + ['url', uploadedImage.url], + ['a', spotRef], + ['alt', `Community photo for ${spotTitle}`], + ]; + + if (uploadedImage.mimeType) { + eventTags.push(['m', uploadedImage.mimeType]); + } + + await publishEvent({ + kind: PHOTO_CONTRIBUTION_KIND, + content: caption.trim(), + tags: eventTags, + }); + + toast({ + title: 'Photo contributed!', + description: 'Your photo has been added to this spot.', + }); + + // Reset and close + setCaption(''); + setUploadedImage(null); + setOpen(false); + + // Invalidate the photos query + queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] }); + } catch (error) { + toast({ + title: 'Failed to contribute', + description: error instanceof Error ? error.message : 'Something went wrong', + variant: 'destructive', + }); + } + }; + + const handleClose = () => { + setOpen(false); + setCaption(''); + setUploadedImage(null); + }; + + return ( + + + + + + + + + Add Photo to Spot + + + Contribute a photo to help others discover this spot. + + + + {!user ? ( +
+

Log in to contribute photos

+ +
+ ) : ( +
+ {/* Photo upload area */} + {uploadedImage ? ( +
+ Uploaded + +
+ ) : ( + + + + + + )} + + {/* Caption */} +
+