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,109 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Heading1, Heading2, Heading3, Bold, Italic, Strikethrough, Quote, Code2, Link, List, ListOrdered, ListTodo, Table2, AtSign, MessageSquare, CaseSensitive, WrapText, Send } from "@lucide/svelte";
|
||||
import { openInBrowser } from "../git";
|
||||
export let value = "";
|
||||
export let language: "de" | "en" = "en";
|
||||
export let disabled = false;
|
||||
export let busy = false;
|
||||
export let onSend: () => void | Promise<void>;
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let preview = false;
|
||||
let monospace = false;
|
||||
let wrap = true;
|
||||
let linkError = "";
|
||||
$: de = language === "de";
|
||||
$: rendered = preview ? DOMPurify.sanitize(marked.parse(value, { async: false, gfm: true, breaks: true }), {
|
||||
ALLOWED_TAGS: ["p","br","strong","em","del","s","blockquote","pre","code","h1","h2","h3","h4","h5","h6","ul","ol","li","a","table","thead","tbody","tr","th","td","hr","input"],
|
||||
ALLOWED_ATTR: ["href","title","type","checked","disabled","align"],
|
||||
}) : "";
|
||||
$: tools = [
|
||||
{icon:Heading1,label:de?"Überschrift 1":"Heading 1",prefix:"# ",line:true},
|
||||
{icon:Heading2,label:de?"Überschrift 2":"Heading 2",prefix:"## ",line:true},
|
||||
{icon:Heading3,label:de?"Überschrift 3":"Heading 3",prefix:"### ",line:true},
|
||||
{icon:Bold,label:de?"Fett":"Bold",prefix:"**",suffix:"**",group:true},
|
||||
{icon:Italic,label:de?"Kursiv":"Italic",prefix:"_",suffix:"_"},
|
||||
{icon:Strikethrough,label:de?"Durchgestrichen":"Strikethrough",prefix:"~~",suffix:"~~"},
|
||||
{icon:Quote,label:de?"Zitat":"Quote",prefix:"> ",line:true,group:true},
|
||||
{icon:Code2,label:"Code",prefix:"`",suffix:"`"},
|
||||
{icon:Link,label:"Link",prefix:"[",suffix:"](https://example.com)"},
|
||||
{icon:List,label:de?"Aufzählung":"Bullet list",prefix:"- ",line:true,group:true},
|
||||
{icon:ListOrdered,label:de?"Nummerierte Liste":"Numbered list",prefix:"1. ",line:true},
|
||||
{icon:ListTodo,label:de?"Aufgabenliste":"Task list",prefix:"- [ ] ",line:true},
|
||||
{icon:Table2,label:de?"Tabelle":"Table",prefix:"\n| A | B |\n| --- | --- |\n| | |\n"},
|
||||
{icon:AtSign,label:de?"Erwähnung einfügen":"Insert mention",prefix:"@",suffix:"",group:true},
|
||||
{icon:MessageSquare,label:de?"Issue-/PR-Verweis einfügen":"Insert issue/PR reference",prefix:"#",suffix:""},
|
||||
];
|
||||
async function format(prefix: string, suffix = "", line = false) {
|
||||
if (disabled || busy) return;
|
||||
preview = false;
|
||||
await tick();
|
||||
let start = textarea.selectionStart, end = textarea.selectionEnd;
|
||||
if (line) {
|
||||
start = value.lastIndexOf("\n", start - 1) + 1;
|
||||
const selected = value.slice(start, end);
|
||||
const replacement = selected.split("\n").map((text, i) => `${prefix === "1. " ? `${i + 1}. ` : prefix}${text}`).join("\n");
|
||||
value = value.slice(0,start) + replacement + value.slice(end);
|
||||
await tick(); textarea.focus(); textarea.setSelectionRange(start, start+replacement.length);
|
||||
} else {
|
||||
const selected = value.slice(start,end);
|
||||
// Multi-line code selections become fenced blocks.
|
||||
if (prefix === "`" && selected.includes("\n")) { prefix = "\n```\n"; suffix = "\n```\n"; }
|
||||
value = value.slice(0,start)+prefix+selected+suffix+value.slice(end);
|
||||
await tick(); textarea.focus(); textarea.setSelectionRange(start+prefix.length,start+prefix.length+selected.length);
|
||||
}
|
||||
}
|
||||
function keyboard(event: KeyboardEvent) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "b" || key === "i" || key === "k") { event.preventDefault(); void format(key === "b" ? "**" : key === "i" ? "_" : "[",key === "b" ? "**" : key === "i" ? "_" : "](https://example.com)"); }
|
||||
if (key === "enter") { event.preventDefault(); if (value.trim() && !busy && !disabled) void onSend(); }
|
||||
}
|
||||
function previewLinks(node: HTMLElement) {
|
||||
node.addEventListener("click", previewClick);
|
||||
return { destroy() { node.removeEventListener("click", previewClick); } };
|
||||
}
|
||||
async function previewClick(event: MouseEvent) {
|
||||
const anchor = event.target instanceof Element ? event.target.closest("a") : null;
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
if (!/^https?:\/\//i.test(href)) return;
|
||||
try { await openInBrowser(href); } catch (cause) { linkError = String(cause); }
|
||||
}
|
||||
</script>
|
||||
<div class="md-editor">
|
||||
<div class="md-tabs" role="group" aria-label={de ? "Editor-Ansicht" : "Editor view"}>
|
||||
<button type="button" class:active={!preview} aria-pressed={!preview} onclick={() => { preview = false; }}>{de ? "Schreiben" : "Write"}</button>
|
||||
<button type="button" class:active={preview} aria-pressed={preview} onclick={() => { preview = true; }}>{de ? "Vorschau" : "Preview"}</button>
|
||||
</div>
|
||||
<div class="md-toolbar" role="group" aria-label={de ? "Markdown-Formatierung" : "Markdown formatting"}>
|
||||
{#each tools as tool}<button type="button" class:group-start={tool.group} title={tool.label} aria-label={tool.label} disabled={disabled || busy || preview} onmousedown={event => event.preventDefault()} onclick={() => format(tool.prefix,tool.suffix,tool.line)}><svelte:component this={tool.icon} size={15} strokeWidth={1.7} /></button>{/each}
|
||||
<span class="md-spacer"></span>
|
||||
<button type="button" title={de ? "Monospace-Schrift" : "Monospace font"} aria-label={de ? "Monospace-Schrift" : "Monospace font"} aria-pressed={monospace} onclick={() => { monospace = !monospace; }}><CaseSensitive size={16} /></button>
|
||||
<button type="button" title={de ? "Zeilenumbruch" : "Line wrap"} aria-label={de ? "Zeilenumbruch" : "Line wrap"} aria-pressed={wrap} onclick={() => { wrap = !wrap; }}><WrapText size={16} /></button>
|
||||
</div>
|
||||
{#if preview}
|
||||
<!-- Rendered Markdown is restricted to safe content tags and sanitized before insertion. -->
|
||||
<div class="md-preview" role="region" aria-label={de ? "Kommentarvorschau" : "Comment preview"} use:previewLinks>
|
||||
{#if value.trim()}{@html rendered}{:else}<span>{de ? "Noch nichts zum Anzeigen." : "Nothing to preview yet."}</span>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<textarea bind:this={textarea} bind:value class:monospace wrap={wrap ? "soft" : "off"} maxlength="100000" rows="5" disabled={disabled || busy} aria-label={de ? "Kommentar schreiben" : "Write comment"} placeholder={de ? "Kommentar hinzufügen …" : "Leave a comment …"} onkeydown={keyboard}></textarea>
|
||||
{/if}
|
||||
{#if linkError}<small role="alert">{linkError}</small>{/if}
|
||||
<div class="md-footer"><small>Markdown <span>· Ctrl/⌘ + Enter</span></small><button class="md-send" type="button" disabled={!value.trim() || disabled || busy} onclick={() => onSend()}><Send size={13} />{busy ? (de ? "Wird gesendet …" : "Sending …") : (de ? "Kommentar senden" : "Post comment")}</button></div>
|
||||
</div>
|
||||
<style>
|
||||
.md-editor{width:100%;min-width:0;color:var(--color-ink);font:400 12px/1.5 var(--font-sans)}
|
||||
.md-editor button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:0;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent;font:inherit;cursor:pointer;box-shadow:none}
|
||||
.md-editor button:disabled{opacity:.4;cursor:default}.md-editor button:focus-visible{outline:2px solid var(--color-accent);outline-offset:1px}.md-editor button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-hover)}
|
||||
.md-tabs{display:flex;border-bottom:1px solid var(--color-border);gap:1px}.md-tabs button{position:relative;height:36px;padding:0 12px;border:1px solid transparent;border-bottom:0;font-size:12px}.md-tabs button.active{border-color:var(--color-border);color:var(--color-ink);background:var(--app-bg);font-weight:600}.md-tabs button.active:after{content:"";position:absolute;bottom:-1px;left:0;right:0;height:1px;background:var(--app-bg)}
|
||||
.md-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:10px 2px;row-gap:6px}.md-toolbar button{width:25px;height:25px;flex-shrink:0}.md-toolbar .group-start{border-left:1px solid var(--color-border);margin-left:4px;padding-left:5px;width:30px}.md-spacer{flex:1}.md-toolbar button[aria-pressed=true]{color:var(--color-accent)}
|
||||
.md-editor textarea,.md-preview{box-sizing:border-box;width:100%;min-height:132px;max-height:300px;margin:0;padding:11px 12px;border:1px solid var(--color-border-input);border-radius:3px;color:var(--color-ink);background:var(--app-input-bg);font:400 12px/1.6 var(--font-sans);overflow:auto;resize:vertical}.md-editor textarea:focus{outline:none;box-shadow:none;border-color:var(--color-accent)}.md-editor textarea.monospace{font-family:var(--font-mono)}.md-editor textarea::placeholder{color:var(--color-ink-faint)}.md-preview{overflow-wrap:anywhere;resize:none}.md-preview>span{color:var(--color-ink-faint)}
|
||||
.md-preview :global(p){margin:0 0 10px;white-space:normal}.md-preview :global(h1),.md-preview :global(h2),.md-preview :global(h3){margin:12px 0 8px;line-height:1.3}.md-preview :global(h1){font-size:22px}.md-preview :global(h2){font-size:18px}.md-preview :global(h3){font-size:15px}.md-preview :global(ul){list-style:disc;padding-left:24px}.md-preview :global(ol){list-style:decimal;padding-left:24px}.md-preview :global(blockquote){border-left:3px solid var(--color-accent);padding-left:12px;margin:10px 0;color:var(--color-ink-muted)}.md-preview :global(pre){overflow:auto;background:var(--color-surface);padding:10px}.md-preview :global(code){font-family:var(--font-mono)}.md-preview :global(table){border-collapse:collapse;max-width:100%;margin:10px 0}.md-preview :global(th),.md-preview :global(td){border:1px solid var(--color-border);padding:5px 9px;font:inherit}.md-preview :global(a){color:var(--color-accent);text-decoration:underline}.md-preview :global(input[type=checkbox]){width:auto;margin-right:6px}
|
||||
.md-footer{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:9px}.md-footer small{font-size:10px;color:var(--color-ink-faint)}.md-editor .md-send{height:32px;padding:0 10px;border:1px solid var(--color-accent);color:var(--color-accent);font-size:11px;white-space:nowrap}.md-editor .md-send:hover:not(:disabled){background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
|
||||
@media(max-width:420px){.md-footer small span{display:none}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user