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: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default tseslint.config(
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
Expand Down
33 changes: 33 additions & 0 deletions frontend/components/CopyWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Button, ButtonProps } from "@heroui/react"
import { useRef, useState } from "react"
import { CopyIcon, CheckIcon } from "./icons.js"

interface CopyIconProps extends ButtonProps {
getCopyContent: () => string
}

export function CopyWidget({ className, getCopyContent, ...rest }: CopyIconProps) {
const numOfIssuedCopies = useRef(0)
const [hasIssuedCopies, setHasIssuedCopies] = useState<boolean>(false)
const onCopy = () => {
const content = getCopyContent()
navigator.clipboard
.writeText(content)
.then(() => {
numOfIssuedCopies.current = numOfIssuedCopies.current + 1
setHasIssuedCopies(numOfIssuedCopies.current > 0)

setTimeout(() => {
numOfIssuedCopies.current = numOfIssuedCopies.current - 1
setHasIssuedCopies(numOfIssuedCopies.current > 0)
}, 1000)
})
.catch(console.error)
}

return (
<Button isIconOnly aria-label="Copy" className={className} onPress={onCopy} {...rest}>
{hasIssuedCopies ? <CheckIcon className="size-6" /> : <CopyIcon className="size-6" />}
</Button>
)
}
76 changes: 47 additions & 29 deletions frontend/components/ErrorModal.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react"
import React from "react"
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalProps } from "@heroui/react"
import React, { useState } from "react"
import { ErrorWithTitle } from "../utils/utils.js"

export type ErrorState = {
title: string
content: string
isOpen: boolean
}

type ErrorModalProps = {
onDismiss: () => void
state: ErrorState
}
type ErrorModalProps = Omit<ModalProps, "children">

export function useErrorModal() {
const [errorState, setErrorState] = useState<ErrorState>({ isOpen: false, content: "", title: "" })

function showModal(title: string, content: string) {
setErrorState({ title, content, isOpen: true })
}

async function handleFailedResp(defaultTitle: string, resp: Response) {
const statusText = resp.statusText === "error" ? "Unknown error" : resp.statusText
const errText = (await resp.text()) || statusText
showModal(defaultTitle, errText)
}

function handleError(defaultTitle: string, error: Error) {
if (error instanceof ErrorWithTitle) {
showModal(error.title, error.message)
} else {
showModal(defaultTitle, error.message)
}
}

const ErrorModal = ({ ...rest }: ErrorModalProps) => {
const onClose = () => {
setErrorState({ isOpen: false, content: "", title: "" })
}
return (
<Modal isOpen={errorState.isOpen} state={errorState} onClose={onClose} {...rest}>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{errorState.title}</ModalHeader>
<ModalBody>
<p>{errorState.content}</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

export function ErrorModal({ onDismiss, state }: ErrorModalProps) {
return (
<Modal
isOpen={state.isOpen}
onOpenChange={(open) => {
if (!open) {
onDismiss()
}
}}
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{state.title}</ModalHeader>
<ModalBody>
<p>{state.content}</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={() => onDismiss()}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
return { ErrorModal, showModal, errorState, handleError, handleFailedResp }
}
18 changes: 10 additions & 8 deletions frontend/components/PasteSettingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CardProps,
Divider,
Input,
mergeClasses,
Radio,
RadioGroup,
Switch,
Expand Down Expand Up @@ -33,12 +34,13 @@ interface PasteSettingPanelProps extends CardProps {
}

export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteSettingPanelProps) {
const radioClassNames = mergeClasses(radioOverrides, { labelWrapper: "ml-2.5" })
return (
<Card aria-label="Pastebin setting panel" classNames={cardOverrides} {...rest}>
<CardHeader className="text-2xl">Settings</CardHeader>
<CardHeader className="text-2xl pl-4 pb-2">Settings</CardHeader>
<Divider className={tst} />
<CardBody>
<div className="gap-4 mb-4 flex flex-row">
<div className="gap-4 mb-3 flex flex-row">
<Input
type="text"
label="Expiration"
Expand Down Expand Up @@ -68,32 +70,32 @@ export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteS
/>
</div>
<RadioGroup
className="gap-4 mb-2 w-full"
className="gap-4 mb-3 w-full"
value={setting.uploadKind}
onValueChange={(v) => onSettingChange({ ...setting, uploadKind: v as UploadKind })}
>
<Radio value="short" description={`Example: ${BaseUrl}/BxWH`} classNames={radioOverrides}>
<Radio value="short" description={`Example: ${BaseUrl}/BxWH`} classNames={radioClassNames}>
Generate a short random URL
</Radio>
<Radio
value="long"
description={`Example: ${BaseUrl}/5HQWYNmjA4h44SmybeThXXAm`}
classNames={{
description: "text-ellipsis max-w-[calc(100vw-5rem)] whitespace-nowrap overflow-hidden",
...radioOverrides,
...radioClassNames,
}}
>
Generate a long random URL
</Radio>
<Radio value="custom" classNames={radioOverrides} description={`Example: ${BaseUrl}/~stocking`}>
<Radio value="custom" classNames={radioClassNames} description={`Example: ${BaseUrl}/~stocking`}>
Set by your own
</Radio>
{setting.uploadKind === "custom" ? (
<Input
value={setting.name}
onValueChange={(n) => onSettingChange({ ...setting, name: n })}
type="text"
classNames={inputOverrides}
classNames={radioClassNames}
isInvalid={!verifyName(setting.name)[0]}
errorMessage={verifyName(setting.name)[1]}
startContent={
Expand All @@ -103,7 +105,7 @@ export function PanelSettingsPanel({ setting, onSettingChange, ...rest }: PasteS
}
/>
) : null}
<Radio value="manage" classNames={radioOverrides}>
<Radio value="manage" classNames={radioClassNames}>
<div className="">Update or delete</div>
</Radio>
{setting.uploadKind === "manage" ? (
Expand Down
131 changes: 76 additions & 55 deletions frontend/components/UploadedPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Card, CardBody, CardHeader, CardProps, Divider, mergeClasses, Skeleton, Snippet } from "@heroui/react"
import React from "react"
import { PasteResponse } from "../../shared/interfaces.js"

import { Card, CardBody, CardHeader, CardProps, CircularProgress, Divider, Input, mergeClasses } from "@heroui/react"

import type { PasteResponse } from "../../shared/interfaces.js"
import { tst } from "../utils/overrides.js"
import { CopyWidget } from "./CopyWidget.js"

interface UploadedPanelProps extends CardProps {
isLoading: boolean
pasteResponse: PasteResponse | null
encryptionKey: string | null
loadingProgress?: number
pasteResponse?: PasteResponse
encryptionKey?: string
}

const makeDecryptionUrl = (url: string, key: string) => {
Expand All @@ -15,62 +19,79 @@ const makeDecryptionUrl = (url: string, key: string) => {
return urlParsed.toString() + "#" + key
}

export function UploadedPanel({ isLoading, pasteResponse, className, encryptionKey, ...rest }: UploadedPanelProps) {
const snippetClassNames = {
pre: `overflow-scroll leading-[2.5] font-sans ${tst}`,
base: `w-full py-1/3 ${tst}`,
copyButton: `relative ml-[-12pt] left-[5pt] ${tst}`,
export function UploadedPanel({
isLoading,
loadingProgress,
pasteResponse,
className,
encryptionKey,
...rest
}: UploadedPanelProps) {
const copyWidgetClassNames = `bg-transparent ${tst} translate-y-[10%]`
const inputProps = {
"aria-labelledby": "",
readOnly: true,
className: "mb-2",
}
const firstColClassNames = "w-[8rem] mr-4 whitespace-nowrap"

return (
<Card classNames={mergeClasses({ base: tst }, { base: className })} {...rest}>
<CardHeader className="text-2xl">Uploaded Paste</CardHeader>
<CardHeader className="text-2xl pl-4 pb-2">Uploaded Paste</CardHeader>
<Divider />
<CardBody>
<table className="border-spacing-2 border-separate table-fixed w-full">
<tbody>
<tr>
<td className={firstColClassNames}>Paste URL</td>
<td className="w-full">
<Skeleton isLoaded={!isLoading} className="rounded-2xl grow">
<Snippet hideSymbol variant="bordered" classNames={snippetClassNames}>
{pasteResponse?.url}
</Snippet>
</Skeleton>
</td>
</tr>
<tr>
<td className={firstColClassNames}>Manage URL</td>
<td className="w-full">
<Skeleton isLoaded={!isLoading} className="rounded-2xl grow">
<Snippet hideSymbol variant="bordered" classNames={snippetClassNames}>
{pasteResponse?.manageUrl}
</Snippet>
</Skeleton>
</td>
</tr>
{encryptionKey ? (
<tr>
<td className={firstColClassNames}>Decryption URL</td>
<td className="w-full">
<Skeleton isLoaded={!isLoading} className="rounded-2xl grow">
<Snippet hideSymbol variant="bordered" classNames={snippetClassNames}>
{pasteResponse && makeDecryptionUrl(pasteResponse.url, encryptionKey)}
</Snippet>
</Skeleton>
</td>
</tr>
) : null}
<tr>
<td className={firstColClassNames}>Expire At</td>
<td className="w-full py-2">
<Skeleton isLoaded={!isLoading} className="rounded-2xl">
{pasteResponse && new Date(pasteResponse.expireAt).toLocaleString()}
</Skeleton>
</td>
</tr>
</tbody>
</table>
{isLoading ? (
<div className={"min-h-[5rem] w-full relative"}>
<CircularProgress
aria-label={"Loading..."}
value={loadingProgress}
className={"absolute top-[50%] left-[50%] translate-[-50%]"}
/>
</div>
) : (
pasteResponse && (
<>
{encryptionKey && (
<Input
{...inputProps}
label={"Decryption URL"}
color={"success"}
value={makeDecryptionUrl(pasteResponse.url, encryptionKey)}
endContent={
<CopyWidget
className={copyWidgetClassNames}
getCopyContent={() => makeDecryptionUrl(pasteResponse.url, encryptionKey)}
/>
}
/>
)}
<Input
{...inputProps}
label={"Paste URL"}
value={pasteResponse.url}
endContent={<CopyWidget className={copyWidgetClassNames} getCopyContent={() => pasteResponse.url} />}
/>
<Input
{...inputProps}
label={"Manage URL"}
value={pasteResponse.manageUrl}
endContent={
<CopyWidget className={copyWidgetClassNames} getCopyContent={() => pasteResponse.manageUrl} />
}
/>
{pasteResponse.suggestedUrl && (
<Input
{...inputProps}
label={"Suggest URL"}
value={pasteResponse.suggestedUrl}
endContent={
<CopyWidget className={copyWidgetClassNames} getCopyContent={() => pasteResponse.suggestedUrl!} />
}
/>
)}
<Input {...inputProps} label={"Expiration"} value={new Date(pasteResponse.expireAt).toLocaleString()} />
</>
)
)}
</CardBody>
</Card>
)
Expand Down
Loading