- 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.
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { Loader2, Upload } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTranslation } from '@/contexts/language';
|
|
|
|
interface PhotoDropzoneProps {
|
|
/** Called with the image files the user picked or dropped. */
|
|
onFiles: (files: File[]) => void | Promise<void>;
|
|
/** Show a spinner and block input while an upload is in flight. */
|
|
uploading?: boolean;
|
|
/** Disable interaction (e.g. while the form is submitting). */
|
|
disabled?: boolean;
|
|
/** Allow more than one file at a time. Defaults to true. */
|
|
multiple?: boolean;
|
|
/** Primary tile label. */
|
|
label?: string;
|
|
/** Secondary line under the label (hidden while dragging). */
|
|
hint?: string;
|
|
/** Wrapper classes — pass sizing such as `aspect-video` here. */
|
|
className?: string;
|
|
/** Accessible name for the file input. Defaults to `label`. */
|
|
inputLabel?: string;
|
|
}
|
|
|
|
const IMAGE_RE = /^image\//;
|
|
|
|
/**
|
|
* A drop target for images that also works as click-to-browse.
|
|
*
|
|
* Users can select or drop several photos at once, instead of adding them one
|
|
* at a time. Collecting the files is all this does — uploading is the caller's
|
|
* responsibility via `onFiles`, so each screen keeps its own toasts/progress.
|
|
*/
|
|
export function PhotoDropzone({
|
|
onFiles,
|
|
uploading = false,
|
|
disabled = false,
|
|
multiple = true,
|
|
label,
|
|
hint,
|
|
className,
|
|
inputLabel,
|
|
}: PhotoDropzoneProps) {
|
|
const { t } = useTranslation();
|
|
const [dragging, setDragging] = useState(false);
|
|
const busy = uploading || disabled;
|
|
const resolvedLabel = label ?? t('spotUi.addPhotos');
|
|
const resolvedHint = hint ?? t('spotUi.dragDropOrClick');
|
|
|
|
const emit = (fileList: FileList | null) => {
|
|
if (!fileList) return;
|
|
const files = Array.from(fileList).filter((f) => IMAGE_RE.test(f.type));
|
|
if (files.length > 0) void onFiles(files);
|
|
};
|
|
|
|
return (
|
|
<label
|
|
onDragEnter={(e) => {
|
|
e.preventDefault();
|
|
if (!busy) setDragging(true);
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (!busy) setDragging(true);
|
|
}}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
setDragging(false);
|
|
if (!busy) emit(e.dataTransfer.files);
|
|
}}
|
|
className={cn(
|
|
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-2 text-center cursor-pointer transition-colors',
|
|
dragging ? 'border-[#ccff00] bg-[#ccff00]/10' : 'border-gray-600 hover:bg-muted/50 hover:border-gray-500',
|
|
busy && 'opacity-60 cursor-not-allowed',
|
|
className,
|
|
)}
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple={multiple}
|
|
className="hidden"
|
|
disabled={busy}
|
|
aria-label={inputLabel ?? resolvedLabel}
|
|
onChange={(e) => {
|
|
emit(e.target.files);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
{busy ? (
|
|
<Loader2 className="h-6 w-6 animate-spin text-[#ccff00]" />
|
|
) : (
|
|
<>
|
|
<Upload className={cn('h-6 w-6 mb-1', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')} />
|
|
<span className={cn('text-xs font-bold', dragging ? 'text-[#ccff00]' : 'text-muted-foreground')}>
|
|
{dragging ? t('spotUi.dropPhotos') : resolvedLabel}
|
|
</span>
|
|
{resolvedHint && <span className="mt-0.5 text-[10px] text-muted-foreground/80">{resolvedHint}</span>}
|
|
</>
|
|
)}
|
|
</label>
|
|
);
|
|
}
|