Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
wip
  • Loading branch information
Aslam97 committed Sep 10, 2024
commit 683f42aefa359c7359cb8c0e9cd98e6f41aaf76a
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/.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")
}
})
127 changes: 98 additions & 29 deletions web/components/custom/sidebar/partial/feedback.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client"

import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
Expand All @@ -11,16 +13,53 @@ import {
DialogPrimitive
} from "@/components/ui/dialog"
import { LaIcon } from "@/components/custom/la-icon"
import { MinimalTiptapEditor } from "@/components/minimal-tiptap"
import { useState } from "react"
import { Content } from "@tiptap/react"
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 [value, setValue] = useState<Content>("")
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>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="icon" className="shrink-0" variant="ghost">
<LaIcon name="CircleHelp" />
Expand All @@ -35,32 +74,62 @@ export function Feedback() {
"flex flex-col p-4 sm:max-w-2xl"
)}
>
<DialogHeader>
<DialogTitle>Share feedback</DialogTitle>
<DialogDescription className="sr-only">
Your feedback helps us improve. Please share your thoughts, ideas, and suggestions
</DialogDescription>
</DialogHeader>
<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>

<MinimalTiptapEditor
value={value}
onChange={setValue}
throttleDelay={500}
className="border-muted-foreground/50 mt-2 min-h-52 rounded-lg"
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"
/>
<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>
<DialogPrimitive.Close className={buttonVariants({ variant: "outline" })}>Cancel</DialogPrimitive.Close>
<Button type="submit">Send feedback</Button>
</DialogFooter>
<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>
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"
46 changes: 34 additions & 12 deletions web/components/minimal-tiptap/components/image/image-edit-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"

import { storeImage } from "@/app/actions"

interface ImageEditBlockProps extends React.HTMLAttributes<HTMLDivElement> {
editor: Editor
close: () => void
Expand All @@ -13,6 +15,8 @@ interface ImageEditBlockProps extends React.HTMLAttributes<HTMLDivElement> {
const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockProps) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [link, setLink] = useState<string>("")
const [isUploading, setIsUploading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)

const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
Expand All @@ -25,19 +29,30 @@ const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockPr
close()
}

const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
if (!files || files.length === 0) return

const reader = new FileReader()
reader.onload = e => {
const src = e.target?.result as string
editor.chain().setImage({ src }).focus().run()
}
setIsUploading(true)
setError(null)

reader.readAsDataURL(files[0])
const formData = new FormData()
formData.append("file", files[0])

close()
try {
const [response, err] = await storeImage(formData)
if (response?.fileModel) {
editor.chain().setImage({ src: response.fileModel.content.src }).focus().run()
close()
} else {
throw new Error("Failed to upload image")
}
} catch (error) {
console.error("Error uploading file:", error)
setError(error instanceof Error ? error.message : "An unknown error occurred")
} finally {
setIsUploading(false)
}
}

const handleSubmit = (e: React.FormEvent) => {
Expand All @@ -64,10 +79,17 @@ const ImageEditBlock = ({ editor, className, close, ...props }: ImageEditBlockPr
</Button>
</div>
</div>
<Button className="w-full" onClick={handleClick}>
Upload from your computer
<Button className="w-full" onClick={handleClick} disabled={isUploading}>
{isUploading ? "Uploading..." : "Upload from your computer"}
</Button>
<input type="file" accept="image/*" ref={fileInputRef} multiple className="hidden" onChange={handleFile} />
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
ref={fileInputRef}
className="hidden"
onChange={handleFile}
/>
{error && <div className="text-destructive text-sm">{error}</div>}
</div>
</form>
)
Expand Down
Loading