Introduce a shared dialog header style (.unified-dialog-header) in app.css and opt dialog components into the new chrome by updating their header markup. Headers now use unified-dialog-icon and unified-dialog-text elements (and import the matching Lucide icons where needed), which standardizes icon placement, title/eyebrow layout, close button styling and responsive behavior. The Command Palette layout was adjusted to include the new header and its grid rows. The CSS is explicitly opt-in (so page/section headers remain unchanged) and includes hover/focus styles and a theme-sensitive close color variable. This commit is a UI refactor only — no API or behavior logic changes.
124 lines
8.8 KiB
Svelte
124 lines
8.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { CirclePlus, X } from "@lucide/svelte";
|
|
import SelectMenu from "./SelectMenu.svelte";
|
|
import { createIntegrationIssue, listIntegrationRepositories, listAzureIssueProjects, listAzureIssueTypes } from "../git";
|
|
import { integrationCredentialKey } from "../integrations";
|
|
import type { GitIntegrationSource, IntegrationIssue, StoredCredential } from "../types";
|
|
|
|
let { source, de, initialRepository = "", loadCredential, onClose, onCreated }: {
|
|
source: GitIntegrationSource; de: boolean; initialRepository?: string;
|
|
loadCredential: (key: string) => Promise<StoredCredential | null>;
|
|
onClose: () => void; onCreated: (issue: IntegrationIssue) => void;
|
|
} = $props();
|
|
let dialog: HTMLDialogElement;
|
|
let titleInput: HTMLInputElement;
|
|
let targets = $state<{ value: string; label: string; group?: string }[]>([]);
|
|
let repository = $state("");
|
|
let types = $state<string[]>([]);
|
|
let workItemType = $state("");
|
|
let title = $state("");
|
|
let description = $state("");
|
|
let loading = $state(true);
|
|
let typesLoading = $state(false);
|
|
let busy = $state(false);
|
|
let loadError = $state("");
|
|
let typeError = $state("");
|
|
let error = $state("");
|
|
let destroyed = false;
|
|
let typeGeneration = 0;
|
|
const azure = $derived(source.provider === "azure-devops");
|
|
const canSubmit = $derived(!loading && !typesLoading && !busy && targets.some(item => item.value === repository) && !!title.trim() && (!azure || types.includes(workItemType)));
|
|
|
|
async function credential() {
|
|
const result = await loadCredential(integrationCredentialKey(source.provider, source.accountId));
|
|
if (!result?.password) throw new Error(de ? "Kein Token gespeichert." : "No token stored.");
|
|
return result;
|
|
}
|
|
async function loadTargets() {
|
|
loading = true; loadError = "";
|
|
try {
|
|
let options: typeof targets;
|
|
if (azure) {
|
|
const auth = await credential();
|
|
const projects = await listAzureIssueProjects(source.baseUrl, auth.username, auth.password);
|
|
options = projects.map(value => ({ value, label: value }));
|
|
} else {
|
|
const repos = await listIntegrationRepositories(source.provider, source.baseUrl, source.accountId);
|
|
options = [...repos].sort((a,b) => a.fullName.localeCompare(b.fullName)).map(repo => ({value: repo.fullName, label: repo.name, group: repo.fullName.includes("/") ? repo.fullName.slice(0, repo.fullName.lastIndexOf("/")) : undefined}));
|
|
}
|
|
if (destroyed) return;
|
|
targets = options;
|
|
const preferred = options.some(item => item.value === initialRepository) ? initialRepository : options.length === 1 ? options[0].value : "";
|
|
await selectTarget(preferred);
|
|
} catch (cause) { if (!destroyed) loadError = String(cause); }
|
|
finally { if (!destroyed) loading = false; }
|
|
}
|
|
async function selectTarget(value: string) {
|
|
repository = value; types = []; workItemType = ""; typeError = "";
|
|
const generation = ++typeGeneration;
|
|
typesLoading = azure && !!value;
|
|
if (!typesLoading) return;
|
|
try {
|
|
const auth = await credential();
|
|
const result = await listAzureIssueTypes(source.baseUrl, auth.username, auth.password, value);
|
|
if (destroyed || generation !== typeGeneration) return;
|
|
types = result;
|
|
if (result.length === 1) workItemType = result[0];
|
|
} catch (cause) { if (!destroyed && generation === typeGeneration) typeError = String(cause); }
|
|
finally { if (!destroyed && generation === typeGeneration) typesLoading = false; }
|
|
}
|
|
async function submit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
if (!canSubmit) return;
|
|
busy = true; error = "";
|
|
try {
|
|
const auth = await credential();
|
|
const issue = await createIntegrationIssue(source.provider, source.baseUrl, auth.username, auth.password, repository, title.trim(), description, workItemType);
|
|
onCreated(issue);
|
|
} catch (cause) { if (!destroyed) error = String(cause); }
|
|
finally { if (!destroyed) busy = false; }
|
|
}
|
|
onMount(() => { dialog.showModal(); titleInput.focus(); void loadTargets(); });
|
|
onDestroy(() => { destroyed = true; typeGeneration++; });
|
|
</script>
|
|
|
|
<dialog bind:this={dialog} aria-labelledby="create-issue-title" oncancel={event => { event.preventDefault(); if (!busy) onClose(); }}>
|
|
<form onsubmit={submit}>
|
|
<header class="unified-dialog-header"><span class="unified-dialog-icon" aria-hidden="true"><CirclePlus size={20}/></span><div class="unified-dialog-text"><h2 id="create-issue-title">{de ? "Neues Issue" : "New issue"}</h2><p>{source.label}</p></div><button class="dialog-close" type="button" aria-label={de ? "Schließen" : "Close"} disabled={busy} onclick={onClose}><X size={18}/></button></header>
|
|
<div class="body">
|
|
<div class="field"><span>{azure ? (de ? "Projekt" : "Project") : "Repository"}</span>
|
|
<SelectMenu value={repository} options={targets} showSelectedGroup searchable disabled={loading || busy} ariaLabel={azure ? (de ? "Projekt" : "Project") : "Repository"} placeholder={loading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Bitte auswählen" : "Select an option")} searchPlaceholder={de ? "Suchen …" : "Search …"} onChange={value => void selectTarget(value)}/>
|
|
</div>
|
|
{#if loadError}<p class="error" role="alert">{loadError}</p><button type="button" disabled={loading || busy} onclick={loadTargets}>{de ? "Erneut laden" : "Retry"}</button>
|
|
{:else if !loading && !targets.length}<p>{de ? "Keine Repositories oder Projekte verfügbar." : "No repositories or projects available."}</p>{/if}
|
|
{#if azure}
|
|
<div class="field"><span>{de ? "Work-Item-Typ" : "Work item type"}</span><SelectMenu value={workItemType} options={types.map(value => ({value,label:value}))} disabled={!repository || typesLoading || busy} ariaLabel={de ? "Work-Item-Typ" : "Work item type"} placeholder={typesLoading ? (de ? "Wird geladen …" : "Loading …") : (de ? "Typ auswählen" : "Select type")} onChange={value => workItemType = value}/></div>
|
|
{#if typeError}<p class="error" role="alert">{typeError}</p><button type="button" disabled={typesLoading || busy} onclick={() => selectTarget(repository)}>{de ? "Typen erneut laden" : "Retry types"}</button>
|
|
{:else if repository && !typesLoading && !types.length}<p>{de ? "Keine Work-Item-Typen verfügbar." : "No work item types available."}</p>{/if}
|
|
{/if}
|
|
<label>{de ? "Titel" : "Title"}<input bind:this={titleInput} bind:value={title} disabled={busy} required placeholder={de ? "Was soll erledigt werden?" : "What needs to be done?"}/></label>
|
|
<label>{de ? "Beschreibung" : "Description"}<textarea bind:value={description} disabled={busy} rows="7" placeholder={de ? "Details zum Issue (optional)" : "Issue details (optional)"}></textarea></label>
|
|
{#if error}<p class="error" role="alert">{de ? "Issue konnte nicht bestätigt werden." : "Issue creation could not be confirmed."} {error}</p>{/if}
|
|
</div>
|
|
<footer><button type="button" disabled={busy} onclick={onClose}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit" disabled={!canSubmit}>{busy ? (de ? "Wird erstellt …" : "Creating …") : (de ? "Issue erstellen" : "Create issue")}</button></footer>
|
|
</form>
|
|
</dialog>
|
|
|
|
<style>
|
|
dialog {margin:auto;width:min(640px,calc(100vw - 32px));max-height:calc(100dvh - 40px);padding:0;border:1px solid var(--color-border);background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font:inherit;overflow:auto}
|
|
dialog::backdrop {background:#0007}
|
|
header {display:flex;align-items:center;gap:12px;padding:18px 24px;background:var(--app-dialog-chrome);border-bottom:1px solid var(--color-border)}
|
|
header>div {flex:1} header>:global(svg) {color:var(--color-accent)}
|
|
h2 {font-size:17px;margin:0} p {margin:4px 0;color:var(--color-ink-dim);font-size:12px}
|
|
.body {display:grid;gap:18px;padding:22px 24px}.field,label {display:grid;gap:8px;min-width:0;font-size:12px}
|
|
button,input,textarea {font:inherit;color:var(--color-ink);border:1px solid var(--color-border);background:var(--color-surface);border-radius:0}
|
|
input,textarea {box-sizing:border-box;width:100%;padding:10px;font-size:13px}textarea {resize:vertical;line-height:1.5}
|
|
button {padding:8px 12px;cursor:pointer;font-size:12px}button:disabled {opacity:.5;cursor:default}
|
|
input:focus-visible,textarea:focus-visible,button:focus-visible {outline:2px solid var(--color-accent);outline-offset:2px}
|
|
.dialog-close {display:grid;place-items:center;padding:6px;border:0;background:transparent}
|
|
.error {color:var(--color-danger,#e0737b);overflow-wrap:anywhere;line-height:1.5}
|
|
footer {display:flex;justify-content:flex-end;gap:10px;padding:16px 24px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}
|
|
.primary {background:var(--color-accent);border-color:var(--color-accent);color:#fff}
|
|
</style>
|