The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
234 lines
7.6 KiB
TypeScript
234 lines
7.6 KiB
TypeScript
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<HTMLInputElement>) => {
|
|
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 (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Camera className="h-4 w-4" />
|
|
Add Photo
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Camera className="h-5 w-5 text-[#ff0040]" />
|
|
Add Photo to Spot
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Contribute a photo to help others discover this spot.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{!user ? (
|
|
<div className="py-6 text-center">
|
|
<p className="text-muted-foreground mb-4">Log in to contribute photos</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Photo upload area */}
|
|
{uploadedImage ? (
|
|
<div className="relative">
|
|
<img
|
|
src={uploadedImage.url}
|
|
alt="Uploaded"
|
|
className="w-full aspect-video object-cover rounded-lg"
|
|
/>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="absolute top-2 right-2 h-8 w-8"
|
|
onClick={() => setUploadedImage(null)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Card className="border-dashed">
|
|
<CardContent className="py-8">
|
|
<label className="flex flex-col items-center cursor-pointer">
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleImageUpload}
|
|
disabled={isUploading}
|
|
/>
|
|
{isUploading ? (
|
|
<>
|
|
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground mb-2" />
|
|
<span className="text-sm text-muted-foreground">Uploading...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="h-10 w-10 text-muted-foreground mb-2" />
|
|
<span className="text-sm text-muted-foreground">Click to upload a photo</span>
|
|
<span className="text-xs text-muted-foreground mt-1">JPG, PNG, WebP</span>
|
|
</>
|
|
)}
|
|
</label>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Caption */}
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
placeholder="Add a caption (optional) - describe what's in the photo, when it was taken, etc."
|
|
value={caption}
|
|
onChange={(e) => setCaption(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 justify-end">
|
|
<Button variant="outline" onClick={handleClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!uploadedImage || isPublishing}
|
|
className="bg-[#ff0040] hover:bg-[#ff0040]/90"
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Contributing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Camera className="h-4 w-4 mr-2" />
|
|
Contribute Photo
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|