247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
import { useState, useCallback, useRef } from 'react';
|
|
import { Blocks, Upload, Loader2 } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { extractWebxdcMeta } from '@/lib/webxdcMeta';
|
|
import { toast } from '@/hooks/useToast';
|
|
|
|
interface WebxdcUploadDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function WebxdcUploadDialog({ open, onOpenChange }: WebxdcUploadDialogProps) {
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: uploadFile } = useUploadFile();
|
|
const { mutateAsync: createEvent } = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [description, setDescription] = useState('');
|
|
const [appName, setAppName] = useState<string | undefined>();
|
|
const [iconUrl, setIconUrl] = useState<string | undefined>();
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isExtracting, setIsExtracting] = useState(false);
|
|
|
|
const reset = useCallback(() => {
|
|
setFile(null);
|
|
setDescription('');
|
|
setAppName(undefined);
|
|
setIconUrl(undefined);
|
|
setIsUploading(false);
|
|
setIsExtracting(false);
|
|
}, []);
|
|
|
|
const handleOpenChange = useCallback((next: boolean) => {
|
|
if (!next) reset();
|
|
onOpenChange(next);
|
|
}, [onOpenChange, reset]);
|
|
|
|
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selected = e.target.files?.[0];
|
|
if (!selected) return;
|
|
|
|
setFile(selected);
|
|
setIsExtracting(true);
|
|
|
|
try {
|
|
const meta = await extractWebxdcMeta(selected);
|
|
setAppName(meta.name);
|
|
|
|
// Upload icon if present
|
|
if (meta.iconFile) {
|
|
try {
|
|
const iconTags = await uploadFile(meta.iconFile);
|
|
const [[, url]] = iconTags;
|
|
setIconUrl(url);
|
|
} catch {
|
|
// Icon upload failed, continue without it
|
|
}
|
|
}
|
|
} catch {
|
|
// Metadata extraction failed, continue without it
|
|
} finally {
|
|
setIsExtracting(false);
|
|
}
|
|
}, [uploadFile]);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!file || !user) return;
|
|
|
|
setIsUploading(true);
|
|
|
|
try {
|
|
// Re-wrap with correct MIME type (browsers don't know .xdc)
|
|
const uploadableFile = !file.type
|
|
? new File([file], file.name, { type: 'application/x-webxdc' })
|
|
: file;
|
|
|
|
const uploadTags = await uploadFile(uploadableFile);
|
|
let [[, url]] = uploadTags;
|
|
|
|
// Ensure URL ends with .xdc
|
|
if (!url.endsWith('.xdc')) {
|
|
url = url + '.xdc';
|
|
}
|
|
|
|
// Build the kind 1063 tags
|
|
const tags: string[][] = [
|
|
['url', url],
|
|
['m', 'application/x-webxdc'],
|
|
];
|
|
|
|
// Add hash from upload tags
|
|
const hashTag = uploadTags.find(t => t[0] === 'x');
|
|
if (hashTag) tags.push(['x', hashTag[1]]);
|
|
|
|
// Add original hash if present
|
|
const oxTag = uploadTags.find(t => t[0] === 'ox');
|
|
if (oxTag) tags.push(['ox', oxTag[1]]);
|
|
|
|
// Add file size
|
|
const sizeTag = uploadTags.find(t => t[0] === 'size');
|
|
if (sizeTag) tags.push(['size', sizeTag[1]]);
|
|
|
|
// Alt tag with app name
|
|
const altText = appName ? `Webxdc app: ${appName}` : 'Webxdc app';
|
|
tags.push(['alt', altText]);
|
|
|
|
// Webxdc UUID for state coordination
|
|
const uuid = crypto.randomUUID();
|
|
tags.push(['webxdc', uuid]);
|
|
|
|
// App icon thumbnail
|
|
if (iconUrl) tags.push(['image', iconUrl]);
|
|
|
|
await createEvent({
|
|
kind: 1063,
|
|
content: description || (appName ? `${appName}` : ''),
|
|
tags,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
});
|
|
|
|
toast({ title: 'Published', description: `${appName ?? 'Webxdc app'} shared successfully.` });
|
|
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
|
handleOpenChange(false);
|
|
} catch {
|
|
toast({ title: 'Publish failed', description: 'Could not publish the webxdc app.', variant: 'destructive' });
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}, [file, user, appName, iconUrl, description, uploadFile, createEvent, queryClient, handleOpenChange]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Blocks className="size-5" />
|
|
Share Webxdc App
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 pt-2">
|
|
{/* File picker */}
|
|
<div className="space-y-2">
|
|
<Label>App file</Label>
|
|
{file ? (
|
|
<div className="flex items-center gap-3 p-3 rounded-xl border border-border bg-muted/30">
|
|
{iconUrl ? (
|
|
<img
|
|
src={iconUrl}
|
|
alt={appName ?? 'App icon'}
|
|
className="size-10 rounded-xl object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
|
|
<Blocks className="size-5 text-primary" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{appName ?? file.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{isExtracting ? 'Reading metadata...' : file.name}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs"
|
|
onClick={() => {
|
|
reset();
|
|
fileInputRef.current?.click();
|
|
}}
|
|
>
|
|
Change
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="w-full flex flex-col items-center gap-3 p-8 rounded-xl border-2 border-dashed border-border hover:border-primary/50 hover:bg-muted/30 transition-colors cursor-pointer"
|
|
>
|
|
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
|
<Upload className="size-5 text-muted-foreground" />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium">Choose a .xdc file</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Webxdc apps are sandboxed HTML5 archives</p>
|
|
</div>
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".xdc"
|
|
className="hidden"
|
|
onChange={handleFileSelect}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="webxdc-description">Description</Label>
|
|
<Textarea
|
|
id="webxdc-description"
|
|
placeholder="What does this app do?"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!file || isUploading || isExtracting}
|
|
className="w-full rounded-full"
|
|
>
|
|
{isUploading ? (
|
|
<>
|
|
<Loader2 className="size-4 animate-spin mr-2" />
|
|
Publishing...
|
|
</>
|
|
) : (
|
|
'Publish'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|