- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
216 lines
6.8 KiB
TypeScript
216 lines
6.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { Camera, Loader2, X } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
|
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';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
interface AddPhotoToSpotProps {
|
|
spotPubkey: string;
|
|
spotIdentifier: string;
|
|
spotTitle: string;
|
|
}
|
|
|
|
export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) {
|
|
const { t } = useTranslation();
|
|
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 handleFiles = async (files: File[]) => {
|
|
const file = files[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
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: t('spotUi.photoUploaded'),
|
|
description: t('spotUi.photoUploadedDesc'),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: t('spotUi.uploadFailed'),
|
|
description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!uploadedImage) {
|
|
toast({
|
|
title: t('spotUi.noPhoto'),
|
|
description: t('spotUi.uploadPhotoFirst'),
|
|
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: t('spotUi.photoContributed'),
|
|
description: t('spotUi.photoContributedDesc'),
|
|
});
|
|
|
|
// Reset and close
|
|
setCaption('');
|
|
setUploadedImage(null);
|
|
setOpen(false);
|
|
|
|
// Invalidate the photos query
|
|
queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] });
|
|
} catch (error) {
|
|
toast({
|
|
title: t('spotUi.failedToContribute'),
|
|
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
|
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" />
|
|
{t('spotUi.addPhoto')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Camera className="h-5 w-5 text-[#ff0040]" />
|
|
{t('spotUi.addPhotoToSpot')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('spotUi.addPhotoDesc')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{!user ? (
|
|
<div className="py-6 text-center">
|
|
<p className="text-muted-foreground mb-4">{t('spotUi.logInToContributePhotos')}</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Photo upload area */}
|
|
{uploadedImage ? (
|
|
<div className="relative">
|
|
<img
|
|
src={uploadedImage.url}
|
|
alt={t('spotUi.uploadedAlt')}
|
|
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>
|
|
) : (
|
|
<PhotoDropzone
|
|
onFiles={handleFiles}
|
|
uploading={isUploading}
|
|
multiple={false}
|
|
label={t('spotUi.uploadAPhoto')}
|
|
hint={t('spotUi.uploadAPhotoHint')}
|
|
className="py-10"
|
|
/>
|
|
)}
|
|
|
|
{/* Caption */}
|
|
<div className="space-y-2">
|
|
<Textarea
|
|
placeholder={t('spotUi.captionPlaceholder')}
|
|
value={caption}
|
|
onChange={(e) => setCaption(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 justify-end">
|
|
<Button variant="outline" onClick={handleClose}>
|
|
{t('common.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" />
|
|
{t('spotUi.contributing')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Camera className="h-4 w-4 mr-2" />
|
|
{t('spotUi.contributePhoto')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|