Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

NEXT_PUBLIC_JAZZ_PEER_URL="wss://"

RONIN_TOKEN=
# IGNORE_BUILD_ERRORS=true
2 changes: 2 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

.ronin
2 changes: 2 additions & 0 deletions web/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[install.scopes]
ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" }
71 changes: 71 additions & 0 deletions web/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use server"

import { authedProcedure } from "@/lib/utils/auth-procedure"
import { create } from "ronin"
import { z } from "zod"
import { ZSAError } from "zsa"

const MAX_FILE_SIZE = 1 * 1024 * 1024
const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]

export const sendFeedback = authedProcedure
.input(
z.object({
content: z.string()
})
)
.handler(async ({ input, ctx }) => {
const { clerkUser } = ctx
const { content } = input

try {
await create.feedback.with({
message: content,
emailFrom: clerkUser?.emailAddresses[0].emailAddress
})
} catch (error) {
console.error(error)
throw new ZSAError("ERROR", "Failed to send feedback")
}
})

export const storeImage = authedProcedure
.input(
z.object({
file: z.custom<File>(file => {
if (!(file instanceof File)) {
throw new Error("Not a file")
}
if (!ALLOWED_FILE_TYPES.includes(file.type)) {
throw new Error("Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed.")
}
if (file.size > MAX_FILE_SIZE) {
throw new Error("File size exceeds the maximum limit of 1 MB.")
}
return true
})
}),
{ type: "formData" }
)
.handler(async ({ ctx, input }) => {
const { file } = input
const { clerkUser } = ctx

if (!clerkUser?.id) {
throw new ZSAError("NOT_AUTHORIZED", "You are not authorized to upload files")
}

try {
const fileModel = await create.image.with({
content: file,
name: file.name,
type: file.type,
size: file.size
})

return { fileModel }
} catch (error) {
console.error(error)
throw new ZSAError("ERROR", "Failed to store image")
}
})
137 changes: 137 additions & 0 deletions web/components/custom/sidebar/partial/feedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"use client"

import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogPrimitive
} from "@/components/ui/dialog"
import { LaIcon } from "@/components/custom/la-icon"
import { MinimalTiptapEditor, MinimalTiptapEditorRef } from "@/components/minimal-tiptap"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { sendFeedback } from "@/app/actions"
import { useServerAction } from "zsa-react"
import { z } from "zod"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/custom/spinner"

const formSchema = z.object({
content: z.string().min(1, {
message: "Feedback cannot be empty"
})
})

export function Feedback() {
const [open, setOpen] = useState(false)
const editorRef = useRef<MinimalTiptapEditorRef>(null)
const { isPending, execute } = useServerAction(sendFeedback)

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
content: ""
}
})

async function onSubmit(values: z.infer<typeof formSchema>) {
const [, err] = await execute(values)

if (err) {
toast.error("Failed to send feedback")
console.error(err)
return
}

form.reset({ content: "" })
editorRef.current?.editor?.commands.clearContent()
setOpen(false)
toast.success("Feedback sent")
}

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="icon" className="shrink-0" variant="ghost">
<LaIcon name="CircleHelp" />
</Button>
</DialogTrigger>

<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
"flex flex-col p-4 sm:max-w-2xl"
)}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader className="mb-5">
<DialogTitle>Share feedback</DialogTitle>
<DialogDescription className="sr-only">
Your feedback helps us improve. Please share your thoughts, ideas, and suggestions
</DialogDescription>
</DialogHeader>

<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel className="sr-only">Content</FormLabel>
<FormControl>
<MinimalTiptapEditor
{...field}
ref={editorRef}
throttleDelay={500}
className={cn(
"border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg",
{
"border-destructive focus-within:border-destructive": form.formState.errors.content
}
)}
editorContentClassName="p-4 overflow-auto flex grow"
output="html"
placeholder="Your feedback helps us improve. Please share your thoughts, ideas, and suggestions."
autofocus={true}
immediatelyRender={true}
editable={true}
injectCSS={true}
editorClassName="focus:outline-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<DialogFooter className="mt-4">
<DialogPrimitive.Close className={buttonVariants({ variant: "outline" })}>Cancel</DialogPrimitive.Close>
<Button type="submit">
{isPending ? (
<>
<Spinner className="mr-2" />
<span>Sending feedback...</span>
</>
) : (
"Send feedback"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
)
}
3 changes: 3 additions & 0 deletions web/components/custom/sidebar/partial/profile-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { usePathname } from "next/navigation"
import { Feedback } from "./feedback"

export const ProfileSection: React.FC = () => {
const { user, isSignedIn } = useUser()
Expand Down Expand Up @@ -84,6 +85,8 @@ export const ProfileSection: React.FC = () => {
</DropdownMenuContent>
</DropdownMenu>
</div>

<Feedback />
</div>
</div>
)
Expand Down
17 changes: 17 additions & 0 deletions web/components/custom/spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"

interface SpinnerProps extends React.SVGAttributes<SVGElement> {}

export const Spinner = React.forwardRef<SVGSVGElement, SpinnerProps>(({ className, ...props }, ref) => (
<svg ref={ref} className={cn("h-4 w-4 animate-spin", className)} viewBox="0 0 24 24" {...props}>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
))

Spinner.displayName = "Spinner"
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Editor } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react'
import { ImagePopoverBlock } from '../image/image-popover-block'
import { ShouldShowProps } from '../../types'

const ImageBubbleMenu = ({ editor }: { editor: Editor }) => {
const shouldShow = ({ editor, from, to }: ShouldShowProps) => {
if (from === to) {
return false
}

const img = editor.getAttributes('image')

if (img.src) {
return true
}

return false
}

const unSetImage = () => {
editor.commands.deleteSelection()
}

return (
<BubbleMenu
editor={editor}
shouldShow={shouldShow}
tippyOptions={{
placement: 'bottom',
offset: [0, 8]
}}
>
<ImagePopoverBlock onRemove={unSetImage} />
</BubbleMenu>
)
}

export { ImageBubbleMenu }
Loading