Add issue embeds

Signed-off-by: Sebastian Martinez <me@sebastinez.dev>
This commit is contained in:
Sebastian Martinez
2023-09-27 16:40:49 +02:00
parent 2864a0311b
commit 1ef0f9dfd1
28 changed files with 585 additions and 107 deletions
+14 -26
View File
@@ -1,11 +1,5 @@
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher<{ remove: number; click: null }>();
export let removeable: boolean = false;
export let clickable: boolean = false;
export let key: number;
<script lang="ts">
export let actionable: boolean = false;
</script>
<style>
@@ -13,15 +7,12 @@
user-select: none;
display: inline-flex;
justify-content: center;
align-items: center;
color: var(--color-secondary);
}
.clickable:hover {
cursor: pointer;
background-color: var(--color-secondary-5);
align-items: stretch;
color: inherit;
}
.section {
display: flex;
width: 100%;
align-items: center;
max-width: 13.5rem;
padding: 0.2rem 0.5rem;
@@ -30,8 +21,7 @@
background-color: var(--color-secondary-3);
border-radius: var(--border-radius);
}
.close {
align-self: stretch;
.icon {
color: var(--color-secondary);
border: none;
border-bottom-right-radius: var(--border-radius);
@@ -40,25 +30,23 @@
line-height: 1.5;
cursor: pointer;
}
.close:hover {
.icon:hover {
background-color: var(--color-secondary-5);
color: var(--color-foreground);
}
.removeable {
.actionable {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
</style>
<div class="chip">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="section text" class:removeable class:clickable on:click>
<slot />
<span class="section text" class:actionable>
<slot name="content" />
</span>
{#if removeable}
<button class="section close" on:click={() => dispatch("remove", key)}>
✕
</button>
{#if actionable}
<span class="section icon">
<slot name="icon" />
</span>
{/if}
</div>
+6
View File
@@ -8,6 +8,7 @@
export let text: string;
export let small = false;
export let tiny = false;
export let tooltip: string | undefined = undefined;
const dispatch = createEventDispatcher<{ copied: null }>();
@@ -41,6 +42,10 @@
width: 1.5rem;
height: 1.5rem;
}
.clipboard.tiny {
width: 1rem;
height: 1rem;
}
.clipboard:hover :global(svg) {
fill: var(--color-foreground);
}
@@ -55,6 +60,7 @@
title={tooltip}
class="clipboard"
class:small
class:tiny
on:click|stopPropagation={copy}>
<Icon name={icon} />
</span>
-1
View File
@@ -123,7 +123,6 @@
<div style:margin-top="1rem">
<Reactions
reactions={groupedReactions}
clickable={Boolean($httpdStore.state === "authenticated")}
on:remove={event => {
if (id) {
dispatch("react", { id, ...event.detail });
+35 -1
View File
@@ -8,13 +8,25 @@
import markdown from "@app/lib/markdown";
import { Renderer } from "@app/lib/markdown";
import { highlight } from "@app/lib/syntax";
import { isUrl, twemoji, scrollIntoView, canonicalize } from "@app/lib/utils";
import {
isUrl,
twemoji,
scrollIntoView,
canonicalize,
isCommit,
} from "@app/lib/utils";
import { mimes } from "@app/lib/file";
export let content: string;
// If present, resolve all relative links with respect to this URL
export let linkBaseUrl: string | undefined = undefined;
export let path: string = "/";
export let rawPath: string | undefined = undefined;
// If present, means we are in a preview context,
// use this for image previews instead of /raw URLs.
export let embeds:
| Map<string, { name: string; content: string }>
| undefined = undefined;
$: doc = matter(content);
$: frontMatter = Object.entries(doc.data).filter(
@@ -73,6 +85,28 @@
for (const i of container.querySelectorAll("img")) {
const imagePath = i.getAttribute("src");
// If the image is an oid embed
if (imagePath && isCommit(imagePath)) {
const embed = embeds?.get(imagePath);
if (embed) {
const fileExtension = embed.name.split(".").pop();
if (fileExtension) {
i.setAttribute("src", embed.content);
}
} else {
const fileExtension = i.alt.split(".").pop();
const url = new URL(rawPath);
// If a user changes the alt text of an image,
// the browser is still able to infer the mime type.
if (fileExtension && fileExtension in mimes) {
url.search = `?mime=${mimes[fileExtension]}`;
}
url.pathname = canonicalize(`blobs/${imagePath}`, url.pathname);
i.setAttribute("src", url.toString());
}
continue;
}
// Make sure the source isn't a URL before trying to fetch it from the repo
if (
imagePath &&
+24 -11
View File
@@ -2,9 +2,9 @@
import { createEventDispatcher } from "svelte";
import Chip from "@app/components/Chip.svelte";
import { httpdStore } from "@app/lib/httpd";
export let reactions: Map<string, string[]>;
export let clickable: boolean = false;
const dispatch = createEventDispatcher<{
remove: { nids: string[]; reaction: string };
@@ -22,22 +22,35 @@
flex-direction: row;
gap: 0.5rem;
}
.close {
color: inherit;
border: none;
border-bottom-right-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
background-color: transparent;
line-height: 1.5;
padding: 0;
cursor: pointer;
}
.close:hover {
color: var(--color-foreground);
}
</style>
<div class="reactions">
{#each reactions as [reaction, nids], key}
<Chip
{key}
{clickable}
on:click={() => {
if (clickable) {
dispatch("remove", { nids, reaction });
}
}}>
<div class="reaction txt-tiny">
{#each reactions as [reaction, nids]}
<Chip actionable={Boolean($httpdStore.state === "authenticated")}>
<div slot="content" class="reaction txt-tiny">
<span>{reaction}</span>
<span title={nids.join("\n")}>{nids.length}</span>
</div>
<div slot="icon">
<button
class="close"
on:click={() => dispatch("remove", { nids, reaction })}>
✕
</button>
</div>
</Chip>
{/each}
</div>
+19 -1
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { afterUpdate, beforeUpdate, createEventDispatcher } from "svelte";
import { isMac } from "@app/lib/utils";
export let resizable: boolean = false;
export let value: string | number | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let focus: boolean = false;
// Defaulting selectionStart and selectionEnd to 0, since no full support yet.
export let selectionStart = 0;
export let selectionEnd = 0;
let textareaElement: HTMLTextAreaElement | undefined = undefined;
@@ -27,6 +30,19 @@
focus = false;
}
beforeUpdate(() => {
if (textareaElement) {
({ selectionStart, selectionEnd } = textareaElement);
}
});
afterUpdate(() => {
if (textareaElement) {
textareaElement.setSelectionRange(selectionStart, selectionEnd);
textareaElement.focus();
}
});
const dispatch = createEventDispatcher<{
submit: null;
}>();
@@ -95,12 +111,14 @@
<textarea
bind:this={textareaElement}
bind:value
aria-label="textarea-comment"
class="txt-small"
class:resizable
{placeholder}
on:change
on:click
on:input
on:drop
on:keydown|stopPropagation={handleKeydown}
on:keypress />
+33 -2
View File
@@ -7,13 +7,35 @@
import { createEventDispatcher, tick } from "svelte";
import { scrollIntoView } from "@app/lib/utils";
import { httpdStore } from "@app/lib/httpd";
import { embed } from "@app/lib/file";
export let newEmbeds: { name: string; content: string }[] = [];
export let selectionStart = 0;
export let selectionEnd = 0;
export let thread: { root: Comment; replies: Comment[] };
export let rawPath: string;
export let showReplyTextarea = false;
let replyText = "";
function handleFileDrop(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
const embeds = Array.from(event.dataTransfer.files).map(embed);
void Promise.all(embeds).then(embeds =>
embeds.forEach(embed => {
newEmbeds.push({ name: embed.name, content: embed.content });
const embedText = `![${embed.name}](${embed.oid})\n`;
replyText = replyText
.slice(0, selectionStart)
.concat(embedText, replyText.slice(selectionEnd));
selectionStart += embedText.length;
selectionEnd = selectionStart;
}),
);
}
}
function cancel() {
showReplyTextarea = false;
scrollIntoView(root.id, {
@@ -36,12 +58,18 @@
}
function reply() {
dispatch("reply", { id: root.id, body: replyText });
dispatch("reply", { id: root.id, embeds: newEmbeds, body: replyText });
replyText = "";
newEmbeds = [];
showReplyTextarea = false;
}
const dispatch = createEventDispatcher<{
reply: { id: string; body: string };
reply: {
id: string;
embeds: { name: string; content: string }[];
body: string;
};
react: { nids: string[]; commentId: string | undefined; reaction: string };
cancel: never;
}>();
@@ -106,6 +134,9 @@
focus={showReplyTextarea}
bind:value={replyText}
on:submit={reply}
on:drop={handleFileDrop}
bind:selectionStart
bind:selectionEnd
placeholder="Leave your reply" />
<div class="actions">
<Button variant="text" size="small" on:click={cancel}>Dismiss</Button>
+105
View File
@@ -0,0 +1,105 @@
async function parseGitOid(bytes: Uint8Array): Promise<string> {
// Create the header
const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
// Concatenate the header and the original file content
const combined = new Uint8Array(header.length + bytes.length);
combined.set(header);
combined.set(bytes, header.length);
// Compute the SHA-1 hash
const hashBuffer = await crypto.subtle.digest("SHA-1", combined);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert the hash to a hexadecimal string
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
function base64String(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (event.target?.result && typeof event.target.result === "string") {
resolve(event.target.result);
} else {
reject(new Error("Failed to generate base64 string"));
}
};
reader.readAsDataURL(file);
});
}
const mimes: Record<string, string> = {
"3gp": "video/3gpp",
"7z": "application/x-7z-compressed",
aac: "audio/aac",
avi: "video/x-msvideo",
bin: "application/octet-stream",
bmp: "image/bmp",
bz: "application/x-bzip",
bz2: "application/x-bzip2",
csh: "application/x-csh",
css: "text/css",
csv: "text/csv",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
epub: "application/epub+zip",
gz: "application/gzip",
gif: "image/gif",
htm: "text/html",
html: "text/html",
ico: "image/vnd.microsoft.icon",
jar: "application/java-archive",
jpeg: "image/jpeg",
jpg: "image/jpeg",
js: "text/javascript",
json: "application/json",
mjs: "text/javascript",
mp3: "audio/mpeg",
mp4: "video/mp4",
mpeg: "video/mpeg",
odp: "application/vnd.oasis.opendocument.presentation",
ods: "application/vnd.oasis.opendocument.spreadsheet",
odt: "application/vnd.oasis.opendocument.text",
oga: "audio/ogg",
ogv: "video/ogg",
ogx: "application/ogg",
otf: "font/otf",
png: "image/png",
pdf: "application/pdf",
php: "application/x-httpd-php",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
rar: "application/vnd.rar",
rtf: "application/rtf",
sh: "application/x-sh",
svg: "image/svg+xml",
tar: "application/x-tar",
tif: "image/tiff",
tiff: "image/tiff",
ttf: "font/ttf",
txt: "text/plain",
wav: "audio/wav",
weba: "audio/webm",
webm: "video/webm",
webp: "image/webp",
woff: "font/woff",
woff2: "font/woff2",
xhtml: "application/xhtml+xml",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xml: "application/xml",
zip: "application/zip",
};
async function embed(
file: File,
): Promise<{ oid: string; name: string; content: string }> {
const bytes = new Uint8Array(await file.arrayBuffer());
const oid = await parseGitOid(bytes);
const content = await base64String(file);
return { oid, name: file.name, content };
}
export { embed, mimes };
+4
View File
@@ -166,6 +166,10 @@ export function isUrl(input: string): boolean {
return /^https?:\/\//.test(input);
}
export function isCommit(input: string): boolean {
return /^[a-f0-9]{40}$/.test(input);
}
export function isFulfilled<T>(
input: PromiseSettledResult<T>,
): input is PromiseFulfilledResult<T> {
+27 -10
View File
@@ -37,9 +37,9 @@
}
}
function removeAssignee({ detail: key }: { detail: number }) {
updatedAssignees = updatedAssignees.filter((_, i) => i !== key);
if (action === "create") {
function removeAssignee(remove: string) {
updatedAssignees = updatedAssignees.filter(assignee => assignee !== remove);
if (action === "create" || action === "edit") {
dispatch("save", updatedAssignees);
}
}
@@ -62,11 +62,25 @@
margin-bottom: 1.25rem;
}
.close {
color: inherit;
border: none;
border-bottom-right-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
background-color: transparent;
line-height: 1.5;
padding: 0;
cursor: pointer;
}
.close:hover {
color: var(--color-foreground);
}
.chip-content {
display: flex;
align-items: center;
width: 100%;
gap: 0.5rem;
white-space: nowrap;
}
</style>
@@ -97,15 +111,18 @@
{/if}
</div>
<div class="body">
{#each updatedAssignees as assignee, key (assignee)}
<Chip
on:remove={removeAssignee}
removeable={editInProgress || action === "create"}
{key}>
<div aria-label="chip" class="chip-content">
{#each updatedAssignees as assignee (assignee)}
<Chip actionable={editInProgress || action === "create"}>
<div slot="content" aria-label="chip" class="txt-overflow chip-content">
<Avatar inline nodeId={assignee} />
<span>{formatNodeId(assignee)}</span>
</div>
<button
slot="icon"
class="section close"
on:click={() => removeAssignee(assignee)}>
✕
</button>
</Chip>
{:else}
<div class="txt-missing">No assignees</div>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts" strictEvents>
import Chip from "@app/components/Chip.svelte";
import Clipboard from "@app/components/Clipboard.svelte";
export let embeds: { name: string; content: string }[] = [];
</script>
<style>
.header {
display: flex;
gap: 1rem;
align-items: center;
font-size: var(--font-size-small);
margin-bottom: 0.75rem;
color: var(--color-foreground-6);
}
.body {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.chip-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
</style>
<div>
<div class="header">
<span>Attachments</span>
</div>
<div class="body">
{#each embeds as embed}
<Chip actionable>
<div slot="content" aria-label="chip" class="chip-content">
<span class="txt-overflow">{embed.name}</span>
</div>
<Clipboard
slot="icon"
text={`![${embed.name}](${embed.content.substring(4)})`}
tiny />
</Chip>
{:else}
<div class="txt-missing">No attachments</div>
{/each}
</div>
</div>
+2 -1
View File
@@ -4,7 +4,8 @@
export let title: string;
export let subtitle: string[];
export let error: Error;
// This is more explicit than the standard error type.
export let error: { message: string; stack?: string };
</script>
<Modal {title} emoji="🚨">
+24 -14
View File
@@ -24,7 +24,7 @@
} else {
updatedLabels = [...updatedLabels, sanitizedValue];
inputValue = "";
if (action === "create") {
if (action === "create" || action === "edit") {
dispatch("save", updatedLabels);
}
}
@@ -33,9 +33,9 @@
}
}
function removeLabel({ detail: key }: { detail: number }) {
updatedLabels = updatedLabels.filter((_, i) => i !== key);
if (action === "create") {
function removeLabel(remove: string) {
updatedLabels = updatedLabels.filter(label => label !== remove);
if (action === "create" || action === "edit") {
dispatch("save", updatedLabels);
}
}
@@ -57,10 +57,18 @@
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.close {
color: inherit;
border: none;
border-bottom-right-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
background-color: transparent;
line-height: 1.5;
padding: 0;
cursor: pointer;
}
.close:hover {
color: var(--color-foreground);
}
</style>
@@ -91,12 +99,14 @@
{/if}
</div>
<div class="metadata-section-body">
{#each updatedLabels as label, key (label)}
<Chip
on:remove={removeLabel}
removeable={editInProgress || action === "create"}
{key}>
<div aria-label="chip" class="label">{label}</div>
{#each updatedLabels as label}
<Chip actionable={editInProgress || action === "create"}>
<div slot="content" aria-label="chip" class="txt-overflow">{label}</div>
<div slot="icon">
<button class="section close" on:click={() => removeLabel(label)}>
✕
</button>
</div>
</Chip>
{:else}
<div class="txt-missing">No labels</div>
+93 -9
View File
@@ -5,10 +5,12 @@
import { isEqual } from "lodash";
import * as router from "@app/lib/router";
import * as modal from "@app/lib/modal";
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { ResponseError } from "@httpd-client/lib/fetcher";
import { embed } from "@app/lib/file";
import { httpdStore } from "@app/lib/httpd";
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
@@ -17,11 +19,12 @@
import Button from "@app/components/Button.svelte";
import CobHeader from "@app/views/projects/Cob/CobHeader.svelte";
import CobStateButton from "@app/views/projects/Cob/CobStateButton.svelte";
import Embeds from "@app/views/projects/Cob/Embeds.svelte";
import ErrorModal from "@app/views/projects/Cob/ErrorModal.svelte";
import Floating, { closeFocused } from "@app/components/Floating.svelte";
import Icon from "@app/components/Icon.svelte";
import LabelInput from "./Cob/LabelInput.svelte";
import Layout from "./Layout.svelte";
import LabelInput from "@app/views/projects/Cob/LabelInput.svelte";
import Layout from "@app/views/projects/Layout.svelte";
import Markdown from "@app/components/Markdown.svelte";
import ReactionSelector from "@app/components/ReactionSelector.svelte";
import Reactions from "@app/components/Reactions.svelte";
@@ -35,6 +38,9 @@
const rawPath = utils.getRawBasePath(project.id, baseUrl, project.head);
const api = new HttpdClient(baseUrl);
let newEmbeds: { name: string; content: string }[] = [];
let selectionStart = 0;
let selectionEnd = 0;
let action: "edit" | "view";
$: action =
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
@@ -46,9 +52,47 @@
["Close issue as other", { status: "closed", reason: "other" }],
];
const MAX_BLOB_SIZE = 4_194_304;
function handleFileDrop(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
const embeds = Array.from(event.dataTransfer.files).map(embed);
void Promise.all(embeds).then(embeds =>
embeds.forEach(embed => {
if (embed.content.length > MAX_BLOB_SIZE) {
modal.show({
component: ErrorModal,
props: {
title: "File too large",
subtitle: [
"The file you tried to upload is too large.",
"The maximum file size is 4MB.",
],
error: { message: `File ${embed.name} is too large` },
},
});
return;
}
newEmbeds.push({ name: embed.name, content: embed.content });
const embedText = `![${embed.name}](${embed.oid})\n`;
commentBody = commentBody
.slice(0, selectionStart)
.concat(embedText, commentBody.slice(selectionEnd));
selectionStart += embedText.length;
selectionEnd = selectionStart;
}),
);
}
}
async function createReply({
detail: reply,
}: CustomEvent<{ id: string; body: string }>) {
}: CustomEvent<{
id: string;
embeds: { name: string; content: string }[];
body: string;
}>) {
if ($httpdStore.state === "authenticated" && reply.body.trim().length > 0) {
const status = await updateIssue(
project.id,
@@ -56,6 +100,7 @@
{
type: "comment",
body: reply.body,
embeds: reply.embeds,
replyTo: reply.id,
},
$httpdStore.session,
@@ -72,7 +117,12 @@
const status = await updateIssue(
project.id,
issue.id,
{ type: "comment", body, replyTo: issue.id },
{
type: "comment",
body,
embeds: newEmbeds,
replyTo: issue.id,
},
$httpdStore.session,
api,
);
@@ -218,7 +268,10 @@
"There was an error while refreshing this issue.",
"Check your radicle-httpd logs for details.",
],
error,
error: {
message: error.message,
stack: error.stack,
},
},
});
}
@@ -237,7 +290,23 @@
await api.project.updateIssue(projectId, issueId, action, session.id);
return "success";
} catch (error) {
if (error instanceof Error) {
if (error instanceof ResponseError && error.status === 413) {
modal.show({
component: ErrorModal,
props: {
title: "Issue editing failed",
subtitle: [
"Not able to upload the attached file.",
"Try to reduce the size of your attachment.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.body as string,
stack: error.stack,
},
},
});
} else if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
@@ -246,7 +315,10 @@
"There was an error while updating the issue.",
"Check your radicle-httpd logs for details.",
],
error,
error: {
message: error.message,
stack: error.stack,
},
},
});
}
@@ -256,6 +328,13 @@
const issueDescription = issue.discussion[0];
$: embeds = issue.discussion.reduce(
(acc, comment) => {
acc.push(...comment.embeds);
return acc;
},
[] as { name: string; content: string }[],
);
$: selectedItem = issue.state.status === "closed" ? items[0] : items[1];
$: threads = issue.discussion
.filter(
@@ -402,7 +481,6 @@
{#if issueReactions.size > 0}
<div style:margin-top="1rem">
<Reactions
clickable={$httpdStore.state === "authenticated"}
reactions={issueReactions}
on:remove={event =>
handleReaction({ ...event.detail, id: issue.id })} />
@@ -430,8 +508,12 @@
<div style:margin-top="1rem">
<Textarea
resizable
bind:selectionStart
bind:selectionEnd
on:drop={handleFileDrop}
on:submit={async () => {
await createComment(commentBody);
newEmbeds = [];
commentBody = "";
}}
bind:value={commentBody}
@@ -448,6 +530,7 @@
disabled={!commentBody}
on:click={async () => {
await createComment(commentBody);
newEmbeds = [];
commentBody = "";
}}>
Comment
@@ -462,6 +545,7 @@
assignees={issue.assignees}
on:save={saveAssignees} />
<LabelInput {action} labels={issue.labels} on:save={saveLabels} />
<Embeds {embeds} />
</div>
</div>
</Layout>
+29 -2
View File
@@ -5,6 +5,7 @@
import * as router from "@app/lib/router";
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { embed } from "@app/lib/file";
import { httpdStore } from "@app/lib/httpd";
import AssigneeInput from "@app/views/projects/Cob/AssigneeInput.svelte";
@@ -22,6 +23,9 @@
export let baseUrl: BaseUrl;
export let project: Project;
const newEmbeds: Map<string, { name: string; content: string }> = new Map();
let selectionStart = 0;
let selectionEnd = 0;
let preview: boolean = false;
let action: "create" | "view";
$: action =
@@ -32,20 +36,39 @@
: "view";
let issueTitle = "";
let issueText: string | undefined = undefined;
let issueText = "";
let assignees: string[] = [];
let labels: string[] = [];
const api = new HttpdClient(baseUrl);
function handleFileDrop(event: DragEvent) {
event.preventDefault();
if (event.dataTransfer) {
const embeds = Array.from(event.dataTransfer.files).map(embed);
void Promise.all(embeds).then(embeds =>
embeds.forEach(({ oid, name, content }) => {
newEmbeds.set(oid, { name, content });
const embedText = `![${name}](${oid})\n`;
issueText = issueText
.slice(0, selectionStart)
.concat(embedText, issueText.slice(selectionEnd));
selectionStart += embedText.length;
selectionEnd = selectionStart;
}),
);
}
}
async function createIssue(sessionId: string) {
try {
const result = await api.project.createIssue(
project.id,
{
title: issueTitle,
description: issueText ?? "",
description: issueText,
assignees: assignees,
embeds: [...newEmbeds.values()],
labels: labels,
},
sessionId,
@@ -141,6 +164,9 @@
{#if action === "create"}
<Textarea
resizable
bind:selectionStart
bind:selectionEnd
on:drop={handleFileDrop}
bind:value={issueText}
on:submit={() => {
void createIssue(session.id);
@@ -150,6 +176,7 @@
<p class="txt-missing">No description</p>
{:else}
<Markdown
embeds={newEmbeds}
content={issueText}
rawPath={utils.getRawBasePath(
project.id,