mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-05 10:01:22 +02:00
wip
This commit is contained in:
45
5002.md
Normal file
45
5002.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
layout: default
|
||||
title: Translation
|
||||
description: Translate input(s)
|
||||
---
|
||||
|
||||
# Input
|
||||
|
||||
Job requests SHOULD include 1 one or more inputs. Inputs should be readily available and not require any form of text-processing or extraction (i.e. inputs should not point to audio-files or HTML URLs)
|
||||
|
||||
# Params
|
||||
|
||||
## `language`
|
||||
|
||||
Specifies the desired output language
|
||||
|
||||
```json
|
||||
[ "param", "language", "es" ]
|
||||
```
|
||||
|
||||
# Output
|
||||
|
||||
Including but not limited to:
|
||||
|
||||
* `text/plain`
|
||||
|
||||
|
||||
# Example
|
||||
|
||||
## Translate an event to spanish
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "",
|
||||
"kind": 5002,
|
||||
"tags": [
|
||||
[ "i", "<hexid>", "event" ],
|
||||
[ "param", "language", "es" ]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
# Appendix A
|
||||
|
||||
Langue codes SHOULD be on [ISO 639-2/ISO 639-1](https://www.loc.gov/standards/iso639-2/php/code_list.php)
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -55,7 +55,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lexical": "^0.36.2",
|
||||
@@ -7347,9 +7347,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lexical": "^0.36.2",
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useReactions, useReact } from '@/hooks/useReactions';
|
||||
import { useTranslationJob } from '@/hooks/useTranslationJob';
|
||||
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||
import { HighlightsSection } from '@/components/highlights/HighlightsSection';
|
||||
import { TranslationBanner } from '@/components/TranslationBanner';
|
||||
import { TranslationResults } from '@/components/TranslationResults';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { BookmarkButton } from '@/components/BookmarkButton';
|
||||
import { ReadingTime } from '@/components/ReadingTime';
|
||||
@@ -45,6 +48,7 @@ export function ArticleView({ post }: ArticleViewProps) {
|
||||
const author = useAuthor(post.pubkey);
|
||||
const { data: reactions } = useReactions(post.id, post.pubkey);
|
||||
const { mutate: react } = useReact();
|
||||
const { mutate: requestTranslation, isPending: isTranslating } = useTranslationJob();
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.display_name || metadata?.name || genUserName(post.pubkey);
|
||||
@@ -111,6 +115,31 @@ export function ArticleView({ post }: ArticleViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranslate = (dvmPubkey: string, language: string) => {
|
||||
requestTranslation(
|
||||
{
|
||||
eventId: post.id,
|
||||
language,
|
||||
dvmPubkey,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Translation requested",
|
||||
description: `Your translation to ${language} has been requested. Results will appear below.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Failed to request translation",
|
||||
description: "Could not send translation request",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const validDate = isValidDate(date);
|
||||
|
||||
return (
|
||||
@@ -209,6 +238,22 @@ export function ArticleView({ post }: ArticleViewProps) {
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
{/* Translation Banner - only show if user is logged in */}
|
||||
{user && (
|
||||
<>
|
||||
<TranslationBanner
|
||||
eventId={post.id}
|
||||
onTranslate={handleTranslate}
|
||||
/>
|
||||
<Separator className="my-8" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Display existing translations */}
|
||||
<TranslationResults eventId={post.id} />
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 mb-12">
|
||||
<Button
|
||||
variant={hasReacted ? "default" : "outline"}
|
||||
|
||||
226
src/components/TranslationBanner.tsx
Normal file
226
src/components/TranslationBanner.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslationDVMs } from '@/hooks/useTranslationDVMs';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Languages, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import type { TranslationDVM } from '@/hooks/useTranslationDVMs';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface TranslationBannerProps {
|
||||
/** Event ID of the article to translate */
|
||||
eventId: string;
|
||||
/** Callback when translation is requested */
|
||||
onTranslate: (dvmPubkey: string, language: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual DVM card in the scrolling banner
|
||||
*/
|
||||
function DVMCard({ dvm, onClick }: { dvm: TranslationDVM; onClick: () => void }) {
|
||||
const author = useAuthor(dvm.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
const displayName = dvm.metadata.name || metadata?.name || genUserName(dvm.pubkey);
|
||||
const avatarUrl = dvm.metadata.picture || dvm.metadata.image || metadata?.picture;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg hover:bg-accent/50 transition-colors min-w-[100px] group"
|
||||
>
|
||||
<Avatar className="h-12 w-12 ring-2 ring-background group-hover:ring-primary/50 transition-all">
|
||||
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-xs font-medium text-center line-clamp-2 max-w-[100px]">
|
||||
{displayName}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolling banner component for selecting translation DVMs
|
||||
*/
|
||||
export function TranslationBanner({ eventId, onTranslate }: TranslationBannerProps) {
|
||||
const { data: dvms, isLoading } = useTranslationDVMs();
|
||||
const [selectedDVM, setSelectedDVM] = useState<string | null>(null);
|
||||
const [targetLanguage, setTargetLanguage] = useState<string>('es');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleDVMSelect = (dvmPubkey: string) => {
|
||||
setSelectedDVM(dvmPubkey);
|
||||
};
|
||||
|
||||
const handleTranslate = () => {
|
||||
if (selectedDVM) {
|
||||
onTranslate(selectedDVM, targetLanguage);
|
||||
setIsExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Common languages for translation
|
||||
const languages = [
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'vi', name: 'Vietnamese' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5 border-y border-primary/20">
|
||||
<div className="container max-w-4xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dvms || dvms.length === 0) {
|
||||
return null; // Don't show the banner if no DVMs are available
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-primary/5 via-primary/10 to-primary/5 border-y border-primary/20">
|
||||
<div className="container max-w-4xl mx-auto px-4 py-3">
|
||||
{/* Header with toggle */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between gap-3 group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-full">
|
||||
<Languages className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-sm">Translate this article</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{dvms.length} translation service{dvms.length !== 1 ? 's' : ''} available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-4 animate-in slide-in-from-top-2 duration-200">
|
||||
{/* Scrolling DVM banner */}
|
||||
<div className="relative">
|
||||
<div className="overflow-x-auto scrollbar-hide">
|
||||
<div className="flex gap-2 pb-2 min-w-max">
|
||||
{/* Animate the scrolling effect with CSS */}
|
||||
<div className="flex gap-2 animate-scroll-rtl">
|
||||
{dvms.map((dvm) => (
|
||||
<div
|
||||
key={dvm.pubkey}
|
||||
className={`${
|
||||
selectedDVM === dvm.pubkey
|
||||
? 'ring-2 ring-primary rounded-lg'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<DVMCard dvm={dvm} onClick={() => handleDVMSelect(dvm.pubkey)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient overlays for scroll indication */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-gradient-to-r from-background/80 to-transparent pointer-events-none" />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background/80 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Language selection and translate button */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTranslate}
|
||||
disabled={!selectedDVM}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<Languages className="h-4 w-4 mr-2" />
|
||||
Request Translation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedDVM && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Translation will be requested from the selected service
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@keyframes scroll-rtl {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
.animate-scroll-rtl {
|
||||
animation: scroll-rtl 20s linear infinite;
|
||||
}
|
||||
.animate-scroll-rtl:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
src/components/TranslationResults.tsx
Normal file
248
src/components/TranslationResults.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslationResults } from '@/hooks/useTranslationResults';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Languages,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import type { TranslationResult, TranslationFeedback } from '@/hooks/useTranslationResults';
|
||||
|
||||
interface TranslationResultsProps {
|
||||
/** Event ID of the article */
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a single translation result
|
||||
*/
|
||||
function ResultCard({ result }: { result: TranslationResult }) {
|
||||
const author = useAuthor(result.provider);
|
||||
const metadata = author.data?.metadata;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const displayName = metadata?.name || genUserName(result.provider);
|
||||
const avatarUrl = metadata?.picture;
|
||||
|
||||
const languageNames: Record<string, string> = {
|
||||
es: 'Spanish',
|
||||
fr: 'French',
|
||||
de: 'German',
|
||||
it: 'Italian',
|
||||
pt: 'Portuguese',
|
||||
ru: 'Russian',
|
||||
ja: 'Japanese',
|
||||
zh: 'Chinese',
|
||||
ko: 'Korean',
|
||||
ar: 'Arabic',
|
||||
hi: 'Hindi',
|
||||
nl: 'Dutch',
|
||||
pl: 'Polish',
|
||||
tr: 'Turkish',
|
||||
vi: 'Vietnamese',
|
||||
};
|
||||
|
||||
const languageName = result.language ? languageNames[result.language] || result.language : 'Unknown';
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm truncate">{displayName}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Languages className="h-3 w-3 mr-1" />
|
||||
{languageName}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(result.created_at * 1000, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent className="pt-0 animate-in slide-in-from-top-2 duration-200">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||
{result.content}
|
||||
</div>
|
||||
</div>
|
||||
{result.amount && (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
<span>Payment requested: {parseInt(result.amount) / 1000} sats</span>
|
||||
{result.bolt11 && (
|
||||
<Button variant="outline" size="sm" className="ml-auto">
|
||||
Pay Invoice
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display feedback status
|
||||
*/
|
||||
function FeedbackCard({ feedback }: { feedback: TranslationFeedback }) {
|
||||
const author = useAuthor(feedback.provider);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
const displayName = metadata?.name || genUserName(feedback.provider);
|
||||
const avatarUrl = metadata?.picture;
|
||||
|
||||
const statusConfig = {
|
||||
'payment-required': {
|
||||
icon: Zap,
|
||||
color: 'text-yellow-600',
|
||||
bg: 'bg-yellow-100 dark:bg-yellow-900/20',
|
||||
label: 'Payment Required',
|
||||
},
|
||||
processing: {
|
||||
icon: Clock,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
label: 'Processing',
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: 'text-red-600',
|
||||
bg: 'bg-red-100 dark:bg-red-900/20',
|
||||
label: 'Error',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-100 dark:bg-green-900/20',
|
||||
label: 'Success',
|
||||
},
|
||||
partial: {
|
||||
icon: Clock,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-100 dark:bg-purple-900/20',
|
||||
label: 'Partial Result',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[feedback.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`p-3 rounded-lg ${config.bg} border border-border/50`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={avatarUrl} alt={displayName} />
|
||||
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`h-4 w-4 ${config.color}`} />
|
||||
<span className="font-medium text-sm">{config.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{displayName}
|
||||
{feedback.statusInfo && ` • ${feedback.statusInfo}`}
|
||||
</div>
|
||||
{feedback.content && (
|
||||
<div className="mt-2 text-sm text-foreground/80">
|
||||
{feedback.content}
|
||||
</div>
|
||||
)}
|
||||
{feedback.amount && (
|
||||
<Button variant="outline" size="sm" className="mt-2">
|
||||
<Zap className="h-3 w-3 mr-1 text-yellow-500" />
|
||||
Pay {parseInt(feedback.amount) / 1000} sats
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display all translation results and feedback for an article
|
||||
*/
|
||||
export function TranslationResults({ eventId }: TranslationResultsProps) {
|
||||
const { data, isLoading } = useTranslationResults(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || (data.results.length === 0 && data.feedback.length === 0)) {
|
||||
return null; // Don't show anything if there are no results
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Translation Results */}
|
||||
{data.results.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Languages className="h-5 w-5 text-primary" />
|
||||
Available Translations
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{data.results.map((result) => (
|
||||
<ResultCard key={result.event.id} result={result} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job Feedback */}
|
||||
{data.feedback.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Translation Status Updates
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{data.feedback.map((fb) => (
|
||||
<FeedbackCard key={fb.event.id} feedback={fb} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/hooks/useTranslationDVMs.ts
Normal file
83
src/hooks/useTranslationDVMs.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* DVM service announcement metadata
|
||||
*/
|
||||
export interface DVMMetadata {
|
||||
name?: string;
|
||||
about?: string;
|
||||
image?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation DVM service provider
|
||||
*/
|
||||
export interface TranslationDVM {
|
||||
pubkey: string;
|
||||
event: NostrEvent;
|
||||
metadata: DVMMetadata;
|
||||
supportedKinds: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch available translation DVMs (kind 31990 with k:5002)
|
||||
* These are service providers that advertise translation capabilities
|
||||
*/
|
||||
export function useTranslationDVMs() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['translation-dvms'],
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
|
||||
|
||||
// Query for DVM service announcements (kind 31990) that support translation (k:5002)
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [31990], // NIP-89 service announcement
|
||||
'#k': ['5002'], // Translation job kind
|
||||
limit: 50,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
// Transform events into TranslationDVM objects
|
||||
const dvms: TranslationDVM[] = events.map((event) => {
|
||||
let metadata: DVMMetadata = {};
|
||||
|
||||
try {
|
||||
if (event.content) {
|
||||
metadata = JSON.parse(event.content);
|
||||
}
|
||||
} catch (e) {
|
||||
// If content is not valid JSON, metadata stays empty
|
||||
}
|
||||
|
||||
const supportedKinds = event.tags
|
||||
.filter(([name]) => name === 'k')
|
||||
.map(([, value]) => value);
|
||||
|
||||
const tags = event.tags
|
||||
.filter(([name]) => name === 't')
|
||||
.map(([, value]) => value);
|
||||
|
||||
return {
|
||||
pubkey: event.pubkey,
|
||||
event,
|
||||
metadata,
|
||||
supportedKinds,
|
||||
tags,
|
||||
};
|
||||
});
|
||||
|
||||
return dvms;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
63
src/hooks/useTranslationJob.ts
Normal file
63
src/hooks/useTranslationJob.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Parameters for creating a translation job
|
||||
*/
|
||||
export interface TranslationJobParams {
|
||||
/** The event ID to translate */
|
||||
eventId: string;
|
||||
/** Target language code (ISO 639-2 or ISO 639-1) */
|
||||
language: string;
|
||||
/** Optional: specific DVM pubkey to send the job to */
|
||||
dvmPubkey?: string;
|
||||
/** Optional: relay hint where the event can be found */
|
||||
relayHint?: string;
|
||||
/** Optional: maximum bid in millisats */
|
||||
bid?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create and publish translation job requests (kind 5002)
|
||||
*/
|
||||
export function useTranslationJob() {
|
||||
const { mutate: createEvent, mutateAsync: createEventAsync } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (params: TranslationJobParams) => {
|
||||
const { eventId, language, dvmPubkey, relayHint, bid } = params;
|
||||
|
||||
const tags: string[][] = [
|
||||
['i', eventId, 'event', relayHint || '', ''],
|
||||
['param', 'language', language],
|
||||
['output', 'text/plain'],
|
||||
];
|
||||
|
||||
// Add optional DVM pubkey if specified
|
||||
if (dvmPubkey) {
|
||||
tags.push(['p', dvmPubkey]);
|
||||
}
|
||||
|
||||
// Add optional bid if specified
|
||||
if (bid) {
|
||||
tags.push(['bid', bid.toString()]);
|
||||
}
|
||||
|
||||
// Create the job request event
|
||||
const jobRequest = {
|
||||
kind: 5002,
|
||||
content: '',
|
||||
tags,
|
||||
};
|
||||
|
||||
// Publish the job request
|
||||
return new Promise<NostrEvent>((resolve, reject) => {
|
||||
createEvent(jobRequest, {
|
||||
onSuccess: (event) => resolve(event),
|
||||
onError: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
140
src/hooks/useTranslationResults.ts
Normal file
140
src/hooks/useTranslationResults.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Translation result with metadata
|
||||
*/
|
||||
export interface TranslationResult {
|
||||
/** The result event (kind 6002) */
|
||||
event: NostrEvent;
|
||||
/** The original job request event stringified */
|
||||
request?: NostrEvent;
|
||||
/** The translated content */
|
||||
content: string;
|
||||
/** Service provider pubkey */
|
||||
provider: string;
|
||||
/** Target language from the original request */
|
||||
language?: string;
|
||||
/** Payment amount requested in millisats */
|
||||
amount?: string;
|
||||
/** Bolt11 invoice if payment is required */
|
||||
bolt11?: string;
|
||||
/** Creation timestamp */
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Job feedback event with status
|
||||
*/
|
||||
export interface TranslationFeedback {
|
||||
/** The feedback event (kind 7000) */
|
||||
event: NostrEvent;
|
||||
/** Status of the job */
|
||||
status: 'payment-required' | 'processing' | 'error' | 'success' | 'partial';
|
||||
/** Additional status info */
|
||||
statusInfo?: string;
|
||||
/** Service provider pubkey */
|
||||
provider: string;
|
||||
/** Payment amount if required */
|
||||
amount?: string;
|
||||
/** Bolt11 invoice if payment is required */
|
||||
bolt11?: string;
|
||||
/** Partial content if status is 'partial' */
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch translation results for a specific article/event
|
||||
* Queries for both job results (kind 6002) and job feedback (kind 7000)
|
||||
*/
|
||||
export function useTranslationResults(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['translation-results', eventId],
|
||||
queryFn: async (c) => {
|
||||
if (!eventId) {
|
||||
return { results: [], feedback: [] };
|
||||
}
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]);
|
||||
|
||||
// Query for translation results (kind 6002) and feedback (kind 7000)
|
||||
// that reference this article
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [6002, 7000], // Translation results and feedback
|
||||
'#e': [eventId], // References the original article
|
||||
limit: 100,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
// Separate results from feedback
|
||||
const resultEvents = events.filter((e) => e.kind === 6002);
|
||||
const feedbackEvents = events.filter((e) => e.kind === 7000);
|
||||
|
||||
// Parse translation results
|
||||
const results: TranslationResult[] = resultEvents.map((event) => {
|
||||
const requestTag = event.tags.find(([name]) => name === 'request');
|
||||
const amountTag = event.tags.find(([name]) => name === 'amount');
|
||||
|
||||
let request: NostrEvent | undefined;
|
||||
let language: string | undefined;
|
||||
|
||||
if (requestTag && requestTag[1]) {
|
||||
try {
|
||||
request = JSON.parse(requestTag[1]);
|
||||
// Extract language from the request params
|
||||
const paramTag = request?.tags.find(
|
||||
([name, key]) => name === 'param' && key === 'language'
|
||||
);
|
||||
language = paramTag?.[2];
|
||||
} catch (e) {
|
||||
// If request parsing fails, continue without it
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
request,
|
||||
content: event.content,
|
||||
provider: event.pubkey,
|
||||
language,
|
||||
amount: amountTag?.[1],
|
||||
bolt11: amountTag?.[2],
|
||||
created_at: event.created_at,
|
||||
};
|
||||
});
|
||||
|
||||
// Parse feedback events
|
||||
const feedback: TranslationFeedback[] = feedbackEvents.map((event) => {
|
||||
const statusTag = event.tags.find(([name]) => name === 'status');
|
||||
const amountTag = event.tags.find(([name]) => name === 'amount');
|
||||
|
||||
const status = (statusTag?.[1] || 'processing') as TranslationFeedback['status'];
|
||||
const statusInfo = statusTag?.[2];
|
||||
|
||||
return {
|
||||
event,
|
||||
status,
|
||||
statusInfo,
|
||||
provider: event.pubkey,
|
||||
amount: amountTag?.[1],
|
||||
bolt11: amountTag?.[2],
|
||||
content: event.content || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort results by creation time (newest first)
|
||||
results.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return { results, feedback };
|
||||
},
|
||||
enabled: !!eventId,
|
||||
refetchInterval: 10000, // Refetch every 10 seconds to catch new results
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user