Skip to content

Commit

Permalink
Merge pull request stakwork#806 from jordan-ae/image-comp
Browse files Browse the repository at this point in the history
feat: add image paste functionality to text area
  • Loading branch information
humansinstitute authored Dec 30, 2024
2 parents 0071746 + 62e8bfd commit e9e0c91
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 9 deletions.
38 changes: 30 additions & 8 deletions src/components/common/TicketEditor/TicketEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/typedef */
import React, { useEffect, useMemo, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { useStores } from 'store';
Expand All @@ -9,6 +10,7 @@ import {
EuiIcon,
EuiBadge
} from '@elastic/eui';
import { renderMarkdown } from 'people/utils/RenderMarkdown.tsx';
import { phaseTicketStore } from '../../../store/phase';
import {
ActionButton,
Expand All @@ -18,14 +20,14 @@ import {
import {
TicketContainer,
TicketHeader,
TicketTextArea,
TicketInput,
TicketHeaderInputWrap
} from '../../../pages/tickets/style';
import { TicketStatus, Ticket, Author } from '../../../store/interface';
import { Toast } from '../../../people/widgetViews/workspace/interface';
import { uiStore } from '../../../store/ui';
import { Select, Option } from '../../../people/widgetViews/workspace/style.ts';
import { TicketTextAreaComp } from './TicketTextArea.tsx';

interface TicketEditorProps {
ticketData: Ticket;
Expand Down Expand Up @@ -54,7 +56,9 @@ const TicketEditor = observer(
const [selectedVersion, setSelectedVersion] = useState<number>(latestTicket?.version as number);
const [versionTicketData, setVersionTicketData] = useState<Ticket>(latestTicket as Ticket);
const [isCopying, setIsCopying] = useState(false);
const [isPreview, setIsPreview] = useState(false);
const { main } = useStores();
const ui = uiStore;

const groupTickets = useMemo(
() => phaseTicketStore.getTicketsByGroup(ticketData.ticket_group as string),
Expand Down Expand Up @@ -240,6 +244,9 @@ const TicketEditor = observer(
}
};

const handlePreview = () => {
setIsPreview(!isPreview);
};
return (
<TicketContainer>
<EuiFlexGroup alignItems="center" gutterSize="s">
Expand Down Expand Up @@ -279,15 +286,30 @@ const TicketEditor = observer(
>
Copy
</ActionButton>
<ActionButton
color="primary"
onClick={handlePreview}
disabled={isCopying}
data-testid="copy-description-btn"
>
{isPreview ? 'Preview' : 'Edit'}
</ActionButton>
</CopyButtonGroup>
</TicketHeaderInputWrap>
<TicketTextArea
value={versionTicketData.description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setVersionTicketData({ ...versionTicketData, description: e.target.value })
}
placeholder="Enter ticket details..."
/>
{!isPreview ? (
<TicketTextAreaComp
value={versionTicketData.description}
onChange={(value: string) =>
setVersionTicketData({ ...versionTicketData, description: value })
}
placeholder="Enter ticket details..."
ui={ui}
/>
) : (
<div className="p-4 border rounded-md">
{renderMarkdown(versionTicketData.description)}
</div>
)}
<TicketButtonGroup>
<Select
value={selectedVersion}
Expand Down
157 changes: 157 additions & 0 deletions src/components/common/TicketEditor/TicketTextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import styled from 'styled-components';
import { v4 as uuidv4 } from 'uuid';
import React, { ChangeEvent, useState } from 'react';
import { UiStore } from 'store/ui';
import { useDropzone } from 'react-dropzone';

const StyledTextArea = styled.textarea`
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 2px solid #dde1e5;
outline: none;
caret-color: #618aff;
color: #3c3f41;
font-family: 'Barlow';
font-size: 1rem;
font-style: normal;
font-weight: 500;
line-height: 20px;
width: 100%;
resize: vertical;
min-height: 300px;
::placeholder {
color: #b0b7bc;
font-family: 'Barlow';
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
:focus {
border: 2px solid #82b4ff;
}
`;

interface TicketTextAreaProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
ui: UiStore;
}

export const TicketTextAreaComp = ({ value, onChange, placeholder, ui }: TicketTextAreaProps) => {
// eslint-disable-next-line no-unused-vars
const [picsrc, setPicsrc] = useState<string>('');

const uploadBase64Pic = async (img_base64: string, img_type: string, placeholder: string) => {
try {
const info = ui.meInfo;
if (!info) {
alert('You are not logged in.');
return;
}

console.log('User info:', ui.meInfo);
const URL = 'https://people.sphinx.chat';
const response = await fetch(`${URL}/public_pic`, {
method: 'POST',
body: JSON.stringify({ img_base64, img_type }),
headers: {
'x-jwt': info.jwt,
'Content-Type': 'application/json'
}
});

const text = await response.text();
console.log('Raw server response:', text);

let j: { success: boolean; response?: { img: string }; error?: string };
try {
j = JSON.parse(text);
} catch (e) {
console.error('Failed to parse JSON:', text);
throw new Error('Server returned invalid JSON');
}

if (j.success && j.response && j.response.img) {
setPicsrc(img_base64);
const finalMarkdown = `![image](${j.response.img})\n`;
const updatedValue = value.replace(placeholder, finalMarkdown);
onChange(updatedValue);
} else {
throw new Error(j.error || 'Image upload failed');
}
} catch (e) {
console.error('ERROR UPLOADING IMAGE', e);
const failurePlaceholder = `![Upload failed]()\n`;
const updatedValue = value.replace(placeholder, failurePlaceholder);
onChange(updatedValue);
}
};

const handleImageUpload = async (file: File) => {
const uniqueId = uuidv4();
const placeholder = `![Uploading ${uniqueId}...]()\n`;

const textArea = document.querySelector('textarea');
const cursorPosition = textArea?.selectionStart || value.length;
const newValue = value.slice(0, cursorPosition) + placeholder + value.slice(cursorPosition);
onChange(newValue);

try {
const reader = new FileReader();
reader.onload = async (event: ProgressEvent<FileReader>) => {
const base64String = event.target?.result as string;
const base64Data = base64String.split(',')[1];
await uploadBase64Pic(base64Data, file.type, placeholder);
};
reader.readAsDataURL(file);
} catch (error) {
const failurePlaceholder = `![Failed to upload ${uniqueId}...]()\n`;
const updatedValue = value.replace(placeholder, failurePlaceholder);
onChange(updatedValue);
}
};

const onDrop = async (acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file || !file.type.startsWith('image/')) return;
await handleImageUpload(file);
};

const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const { items } = e.clipboardData;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
await handleImageUpload(file);
}
}
}
};

const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept: {
'image/*': []
},
noClick: true,
noKeyboard: true
});

return (
<div {...getRootProps()}>
<input {...getInputProps()} />
<StyledTextArea
value={value}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
onPaste={handlePaste}
placeholder={placeholder}
/>
</div>
);
};
2 changes: 1 addition & 1 deletion src/pages/tickets/workspace/WorkspaceTickets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function WorkspaceBodyComponent() {

useEffect(() => {
getTotalBounties(uuid, checkboxIdToSelectedMap, page);
}, [getTotalBounties]);
}, [checkboxIdToSelectedMap, getTotalBounties, page, uuid]);

const onChangeStatus = (optionId: any) => {
const newCheckboxIdToSelectedMap = {
Expand Down
1 change: 1 addition & 0 deletions src/people/widgetViews/workspace/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,7 @@ export const Option = styled.option`

export const CopyButtonGroup = styled.div`
display: flex;
gap: 10px;
align-items: center;
justify-content: flex-end;
margin-left: auto;
Expand Down

0 comments on commit e9e0c91

Please sign in to comment.