14 changed files with 467 additions and 119 deletions
@ -0,0 +1,233 @@
@@ -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 @@
@@ -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