Files
eranos/src/components/WebxdcUploadDialog.tsx
T

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>
);
}