feat(integrations): add multi-provider board and issue integrations
Add backend integration modules to discover, read, and modify provider-hosted boards, issues, and comments across multiple providers. Expose Tauri commands for board discovery, listing, and card moves, and implement safe issue actions and comment APIs. Wire new Svelte UI components to render boards, issue centers, comments, labels, and assignees, and add sanitized markdown rendering. - Add board discovery, board reading, and card-move APIs - Add Svelte components and styles for integrated board UI - Use marked + DOMPurify for safe markdown rendering
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<script context="module" lang="ts">
|
||||
const drafts = new Map<string, string>();
|
||||
</script>
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import CommentEditor from "./CommentEditor.svelte";
|
||||
import { RefreshCw } from "@lucide/svelte";
|
||||
import IssueAssignees from "./IssueAssignees.svelte";
|
||||
import { listIntegrationIssueComments, addIntegrationIssueComment } from "../git";
|
||||
import { integrationCredentialKey } from "../integrations";
|
||||
import type { GitIntegrationSource, IntegrationIssue, IssueComment, StoredCredential } from "../types";
|
||||
export let source: GitIntegrationSource;
|
||||
export let issue: IntegrationIssue;
|
||||
export let language: "de" | "en";
|
||||
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
|
||||
const key = JSON.stringify([source.id, source.baseUrl, issue.id]);
|
||||
let draft = drafts.get(key) ?? "";
|
||||
let comments: IssueComment[] = [];
|
||||
let cursor: string | null = null;
|
||||
let loading = false;
|
||||
let posting = false;
|
||||
let error = "";
|
||||
let notice = "";
|
||||
let destroyed = false;
|
||||
$: de = language === "de";
|
||||
$: drafts.set(key, draft);
|
||||
onMount(() => { void load(); });
|
||||
onDestroy(() => { destroyed = true; });
|
||||
async function credential() {
|
||||
const saved = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
||||
if (!saved?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
||||
return saved;
|
||||
}
|
||||
async function load(more = false) {
|
||||
if (loading || posting) return;
|
||||
loading = true; error = "";
|
||||
try {
|
||||
const saved = await credential();
|
||||
const page = await listIntegrationIssueComments(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, more ? cursor : null);
|
||||
if (destroyed) return;
|
||||
comments = [...new Map([...(more ? comments : []), ...page.comments].map(comment => [comment.id, comment])).values()];
|
||||
cursor = page.nextCursor;
|
||||
} catch (cause) { if (!destroyed) error = String(cause); }
|
||||
finally { if (!destroyed) loading = false; }
|
||||
}
|
||||
async function submit() {
|
||||
if (!draft.trim() || posting || loading) return;
|
||||
const body = draft;
|
||||
posting = true; error = ""; notice = "";
|
||||
try {
|
||||
const saved = await credential();
|
||||
const comment = await addIntegrationIssueComment(source.provider, source.baseUrl, saved.username, saved.password, issue.repositoryName, issue.number, body);
|
||||
if (drafts.get(key) === body) drafts.delete(key);
|
||||
if (destroyed) return;
|
||||
comments = [...comments.filter(item => item.id !== comment.id), comment];
|
||||
if (draft === body) draft = "";
|
||||
notice = de ? "Kommentar gesendet." : "Comment posted.";
|
||||
} catch (cause) {
|
||||
if (!destroyed) error = `${de ? "Versand nicht bestätigt. Vor erneutem Senden bitte die Kommentare aktualisieren." : "Delivery not confirmed. Refresh comments before resending."} ${String(cause)}`;
|
||||
} finally { if (!destroyed) posting = false; }
|
||||
}
|
||||
function text(comment: IssueComment) {
|
||||
if (!comment.bodyHtml) return comment.body;
|
||||
const document = new DOMParser().parseFromString(comment.body, "text/html");
|
||||
document.querySelectorAll("script, style").forEach(element => element.remove());
|
||||
document.querySelectorAll("br").forEach(element => element.replaceWith("\n"));
|
||||
document.querySelectorAll("p, div, li").forEach(element => element.append("\n"));
|
||||
return document.body.textContent?.trim() ?? "";
|
||||
}
|
||||
</script>
|
||||
<section class="issue-comments" aria-label={de ? "Issue-Kommentare" : "Issue comments"}>
|
||||
<header><h3>{de ? "Kommentare" : "Comments"}</h3><small>{comments.length}{cursor ? "+" : ""}</small><button class="workspace-button close-button" aria-label={de ? "Kommentare aktualisieren" : "Refresh comments"} disabled={loading || posting} onclick={() => load()}><RefreshCw size={14} /></button></header>
|
||||
{#if error}<p class="comment-error" role="alert">{error}</p>{/if}
|
||||
{#if loading}<small role="status">{de ? "Kommentare werden geladen …" : "Loading comments …"}</small>{/if}
|
||||
<div class="issue-comment-list">
|
||||
{#each comments as comment (comment.id)}
|
||||
<article><header><IssueAssignees names={[comment.author || (de ? "Unbekannt" : "Unknown")]} />{#if comment.createdAt}<time datetime={comment.createdAt}>{new Date(comment.createdAt).toLocaleString(de ? "de-DE" : "en-US")}</time>{/if}</header><p>{text(comment)}</p></article>
|
||||
{:else}{#if !loading && !error}<small>{de ? "Noch keine Kommentare." : "No comments yet."}</small>{/if}{/each}
|
||||
</div>
|
||||
{#if cursor}<button class="workspace-button" disabled={loading || posting} onclick={() => load(true)}>{de ? "Weitere Kommentare laden" : "Load more comments"}</button>{/if}
|
||||
<CommentEditor bind:value={draft} {language} disabled={loading} busy={posting} onSend={submit} />
|
||||
{#if notice}<small role="status">{notice}</small>{/if}
|
||||
</section>
|
||||
Reference in New Issue
Block a user