14 changed files with 467 additions and 119 deletions
@ -0,0 +1,233 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { Label } from '@/components/ui/label' |
||||||
|
import { Switch } from '@/components/ui/switch' |
||||||
|
import { Textarea } from '@/components/ui/textarea' |
||||||
|
import { StorageKey } from '@/constants' |
||||||
|
import { useToast } from '@/hooks/use-toast' |
||||||
|
import { createPictureNoteDraftEvent } from '@/lib/draft-event' |
||||||
|
import { useNostr } from '@/providers/NostrProvider' |
||||||
|
import { ChevronDown, LoaderCircle, X } from 'lucide-react' |
||||||
|
import { Dispatch, SetStateAction, useEffect, useState } from 'react' |
||||||
|
import { createPortal } from 'react-dom' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import Lightbox from 'yet-another-react-lightbox' |
||||||
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
||||||
|
import Image from '../Image' |
||||||
|
import Mentions from './Mentions' |
||||||
|
import Uploader from './Uploader' |
||||||
|
|
||||||
|
export default function PicturePostContent({ close }: { close: () => void }) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const { toast } = useToast() |
||||||
|
const { publish, checkLogin } = useNostr() |
||||||
|
const [content, setContent] = useState('') |
||||||
|
const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([]) |
||||||
|
const [posting, setPosting] = useState(false) |
||||||
|
const [showMoreOptions, setShowMoreOptions] = useState(false) |
||||||
|
const [addClientTag, setAddClientTag] = useState(false) |
||||||
|
const canPost = !!content && !posting && pictureInfos.length > 0 |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true') |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||||
|
setContent(e.target.value) |
||||||
|
} |
||||||
|
|
||||||
|
const post = async (e: React.MouseEvent) => { |
||||||
|
e.stopPropagation() |
||||||
|
checkLogin(async () => { |
||||||
|
if (!canPost) { |
||||||
|
close() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
setPosting(true) |
||||||
|
try { |
||||||
|
if (!pictureInfos.length) { |
||||||
|
throw new Error(t('Picture note requires images')) |
||||||
|
} |
||||||
|
const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, { |
||||||
|
addClientTag |
||||||
|
}) |
||||||
|
await publish(draftEvent) |
||||||
|
setContent('') |
||||||
|
close() |
||||||
|
} catch (error) { |
||||||
|
if (error instanceof AggregateError) { |
||||||
|
error.errors.forEach((e) => |
||||||
|
toast({ |
||||||
|
variant: 'destructive', |
||||||
|
title: t('Failed to post'), |
||||||
|
description: e.message |
||||||
|
}) |
||||||
|
) |
||||||
|
} else if (error instanceof Error) { |
||||||
|
toast({ |
||||||
|
variant: 'destructive', |
||||||
|
title: t('Failed to post'), |
||||||
|
description: error.message |
||||||
|
}) |
||||||
|
} |
||||||
|
console.error(error) |
||||||
|
return |
||||||
|
} finally { |
||||||
|
setPosting(false) |
||||||
|
} |
||||||
|
toast({ |
||||||
|
title: t('Post successful'), |
||||||
|
description: t('Your post has been published') |
||||||
|
}) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const onAddClientTagChange = (checked: boolean) => { |
||||||
|
setAddClientTag(checked) |
||||||
|
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString()) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-4"> |
||||||
|
<div className="text-xs text-muted-foreground"> |
||||||
|
{t('A special note for picture-first clients like Olas')} |
||||||
|
</div> |
||||||
|
<PictureUploader pictureInfos={pictureInfos} setPictureInfos={setPictureInfos} /> |
||||||
|
<Textarea |
||||||
|
className="h-32" |
||||||
|
onChange={handleTextareaChange} |
||||||
|
value={content} |
||||||
|
placeholder={t('Write something...')} |
||||||
|
/> |
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<Button |
||||||
|
variant="link" |
||||||
|
className="text-foreground gap-0 px-0" |
||||||
|
onClick={() => setShowMoreOptions((pre) => !pre)} |
||||||
|
> |
||||||
|
{t('More options')} |
||||||
|
<ChevronDown className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`} /> |
||||||
|
</Button> |
||||||
|
<div className="flex gap-2 items-center"> |
||||||
|
<Mentions content={content} /> |
||||||
|
<div className="flex gap-2 items-center max-sm:hidden"> |
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
close() |
||||||
|
}} |
||||||
|
> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button type="submit" disabled={!canPost} onClick={post}> |
||||||
|
{posting && <LoaderCircle className="animate-spin" />} |
||||||
|
{t('Post')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{showMoreOptions && ( |
||||||
|
<div className="space-y-2"> |
||||||
|
<div className="flex items-center space-x-2"> |
||||||
|
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label> |
||||||
|
<Switch |
||||||
|
id="add-client-tag" |
||||||
|
checked={addClientTag} |
||||||
|
onCheckedChange={onAddClientTagChange} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="text-muted-foreground text-xs"> |
||||||
|
{t('Show others this was sent via Jumble')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className="flex gap-2 items-center justify-around sm:hidden"> |
||||||
|
<Button |
||||||
|
className="w-full" |
||||||
|
variant="secondary" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
close() |
||||||
|
}} |
||||||
|
> |
||||||
|
{t('Cancel')} |
||||||
|
</Button> |
||||||
|
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}> |
||||||
|
{posting && <LoaderCircle className="animate-spin" />} |
||||||
|
{t('Post')} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function PictureUploader({ |
||||||
|
pictureInfos, |
||||||
|
setPictureInfos |
||||||
|
}: { |
||||||
|
pictureInfos: { url: string; tags: string[][] }[] |
||||||
|
setPictureInfos: Dispatch< |
||||||
|
SetStateAction< |
||||||
|
{ |
||||||
|
url: string |
||||||
|
tags: string[][] |
||||||
|
}[] |
||||||
|
> |
||||||
|
> |
||||||
|
}) { |
||||||
|
const [index, setIndex] = useState(-1) |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div className="grid grid-cols-3 gap-4"> |
||||||
|
{pictureInfos.map(({ url }, index) => ( |
||||||
|
<div className="relative" key={`${index}-${url}`}> |
||||||
|
<Image |
||||||
|
image={{ url }} |
||||||
|
className="aspect-square w-full rounded-lg" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation() |
||||||
|
setIndex(index) |
||||||
|
}} |
||||||
|
/> |
||||||
|
<Button |
||||||
|
variant="destructive" |
||||||
|
className="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 rounded-full w-6 h-6 p-0" |
||||||
|
onClick={() => { |
||||||
|
setPictureInfos((prev) => prev.filter((_, i) => i !== index)) |
||||||
|
}} |
||||||
|
> |
||||||
|
<X /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
<Uploader |
||||||
|
variant="big" |
||||||
|
onUploadSuccess={({ url, tags }) => { |
||||||
|
setPictureInfos((prev) => [...prev, { url, tags }]) |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{index >= 0 && |
||||||
|
createPortal( |
||||||
|
<div onClick={(e) => e.stopPropagation()}> |
||||||
|
<Lightbox |
||||||
|
index={index} |
||||||
|
slides={pictureInfos.map(({ url }) => ({ src: url }))} |
||||||
|
plugins={[Zoom]} |
||||||
|
open={index >= 0} |
||||||
|
close={() => setIndex(-1)} |
||||||
|
controller={{ |
||||||
|
closeOnBackdropClick: true, |
||||||
|
closeOnPullUp: true, |
||||||
|
closeOnPullDown: true |
||||||
|
}} |
||||||
|
styles={{ toolbar: { paddingTop: '2.25rem' } }} |
||||||
|
/> |
||||||
|
</div>, |
||||||
|
document.body |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,53 @@ |
|||||||
|
import * as React from "react" |
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs" |
||||||
|
|
||||||
|
import { cn } from "@/lib/utils" |
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root |
||||||
|
|
||||||
|
const TabsList = React.forwardRef< |
||||||
|
React.ElementRef<typeof TabsPrimitive.List>, |
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<TabsPrimitive.List |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName |
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef< |
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>, |
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<TabsPrimitive.Trigger |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName |
||||||
|
|
||||||
|
const TabsContent = React.forwardRef< |
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>, |
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> |
||||||
|
>(({ className, ...props }, ref) => ( |
||||||
|
<TabsPrimitive.Content |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
)) |
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName |
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent } |
||||||
Loading…
Reference in new issue