mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-15 19:30:38 +08:00
+14
-26
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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 = `\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
@@ -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 };
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={`})`}
|
||||
tiny />
|
||||
</Chip>
|
||||
{:else}
|
||||
<div class="txt-missing">No attachments</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 = `\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>
|
||||
|
||||
@@ -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 = `\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,
|
||||
|
||||
Reference in New Issue
Block a user