Feature chore bug 007 #21
@@ -15,6 +15,7 @@
|
||||
type LlmStatus,
|
||||
} from "../api";
|
||||
import { renderMarkdown } from "../markdown";
|
||||
import { t } from "../i18n/index.svelte";
|
||||
import TagChip from "../components/TagChip.svelte";
|
||||
import HostedAiBanner from "../components/HostedAiBanner.svelte";
|
||||
import {
|
||||
@@ -91,12 +92,24 @@
|
||||
// the exact same set_llm_provider command (and hence the same "anthropic's
|
||||
// endpoint is fixed" / "switching away resets a stale hosted endpoint"
|
||||
// guarantees — see apply_llm_provider_args in commands.rs).
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
off: "Off",
|
||||
ollama: "Ollama",
|
||||
custom: "Custom",
|
||||
anthropic: "Anthropic (Claude)",
|
||||
};
|
||||
// Provider dropdown values. Labels resolve through `providerLabel` (a
|
||||
// function, not a static map, so a language switch re-renders them);
|
||||
// brand names stay literal, only the generic "Off"/"Custom" are translated.
|
||||
const PROVIDER_IDS = ["off", "ollama", "custom", "anthropic"];
|
||||
function providerLabel(id: string): string {
|
||||
switch (id) {
|
||||
case "off":
|
||||
return t("summary.provider_off");
|
||||
case "custom":
|
||||
return t("summary.provider_custom");
|
||||
case "ollama":
|
||||
return "Ollama";
|
||||
case "anthropic":
|
||||
return "Anthropic (Claude)";
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
}
|
||||
function isHostedStatus(status: LlmStatus | null): boolean {
|
||||
return !!status && status.provider !== "off" && !status.isLocal;
|
||||
}
|
||||
@@ -108,7 +121,7 @@
|
||||
// gate that matters even if the quick switch's own check is skipped, e.g.
|
||||
// "custom" resolving to a hosted endpoint that was configured elsewhere).
|
||||
let showHostedBanner = $state(false);
|
||||
let hostedBannerLabel = $state("this hosted provider");
|
||||
let hostedBannerLabel = $state(t("summary.this_hosted_provider"));
|
||||
let pendingHostedAction = $state<(() => void) | null>(null);
|
||||
function requireHostedAck(providerLabel: string, action: () => void) {
|
||||
if (!settings.settings.hosted_ai_acknowledged) {
|
||||
@@ -145,13 +158,13 @@
|
||||
if (next === "anthropic") {
|
||||
// Deterministically hosted — worth confirming before even switching to
|
||||
// it, not just before the next generate.
|
||||
requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next));
|
||||
requireHostedAck(providerLabel(next), () => void switchProvider(next));
|
||||
} else {
|
||||
void switchProvider(next);
|
||||
}
|
||||
}
|
||||
function onGenerateClick() {
|
||||
const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : "";
|
||||
const label = llmStatus ? providerLabel(llmStatus.provider) : "";
|
||||
if (isHostedStatus(llmStatus)) {
|
||||
requireHostedAck(label, generateSummary);
|
||||
} else {
|
||||
@@ -321,8 +334,8 @@
|
||||
});
|
||||
|
||||
function addTag(raw: string) {
|
||||
const t = raw.trim().toLowerCase();
|
||||
if (t && !pendingTags.includes(t)) pendingTags.push(t);
|
||||
const tag = raw.trim().toLowerCase();
|
||||
if (tag && !pendingTags.includes(tag)) pendingTags.push(tag);
|
||||
}
|
||||
// GitHub-topics-style input: a comma commits everything before it as its
|
||||
// own chip immediately, leaving whatever's after as the live draft.
|
||||
@@ -342,8 +355,8 @@
|
||||
if (tagDraft.trim()) addTag(tagDraft);
|
||||
tagDraft = "";
|
||||
}
|
||||
function removeTag(t: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== t);
|
||||
function removeTag(tag: string) {
|
||||
pendingTags = pendingTags.filter((x) => x !== tag);
|
||||
}
|
||||
|
||||
let savingTags = $state(false);
|
||||
@@ -493,7 +506,7 @@
|
||||
|
||||
<div class="wrap">
|
||||
{#if meetings.selected?.recorded}
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.recording_heading")}</h3>
|
||||
{#if audioSrc}
|
||||
<!-- a meeting recording has no caption track — no svelte-ignore needed,
|
||||
the current eslint-plugin-svelte doesn't flag this element. -->
|
||||
@@ -511,12 +524,12 @@
|
||||
{:else if audioError}
|
||||
<p class="muted">{audioError}</p>
|
||||
{:else}
|
||||
<p class="muted">Loading recording…</p>
|
||||
<p class="muted">{t("summary.loading_recording")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if meetings.selected && settings.settings.sync_enabled}
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> {t("summary.sync_heading")}</h3>
|
||||
<button
|
||||
class="primary upload-now"
|
||||
disabled={meetings.uploading}
|
||||
@@ -526,7 +539,7 @@
|
||||
}}
|
||||
>
|
||||
<UploadCloud size={14} aria-hidden="true" />
|
||||
{meetings.uploading ? "Uploading…" : "Upload now"}
|
||||
{meetings.uploading ? t("summary.uploading") : t("summary.upload_now")}
|
||||
</button>
|
||||
{#if meetings.syncJobs.length}
|
||||
<ul class="jobs">
|
||||
@@ -545,34 +558,36 @@
|
||||
onclick={() => {
|
||||
const m = meetings.selected;
|
||||
if (m) meetings.retryJob(job.jobId, m.id);
|
||||
}}>retry</button
|
||||
}}>{t("summary.retry")}</button
|
||||
>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">No uploads yet for this meeting.</p>
|
||||
<p class="muted">{t("summary.no_uploads")}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Tags size={14} aria-hidden="true" /> Tags</h3>
|
||||
<h3><Tags size={14} aria-hidden="true" /> {t("summary.tags_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to tag it.</p>
|
||||
<p class="muted">{t("summary.tags_select")}</p>
|
||||
{:else}
|
||||
<div class="tag-editor">
|
||||
{#each pendingTags as t (t)}
|
||||
{#each pendingTags as tag (tag)}
|
||||
<TagChip
|
||||
tag={t}
|
||||
{tag}
|
||||
removable
|
||||
onRemove={() => removeTag(t)}
|
||||
onClick={() => meetings.filterByTag(t)}
|
||||
onRemove={() => removeTag(tag)}
|
||||
onClick={() => meetings.filterByTag(tag)}
|
||||
/>
|
||||
{/each}
|
||||
<input
|
||||
class="tag-input"
|
||||
list="known-tags"
|
||||
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
|
||||
placeholder={pendingTags.length
|
||||
? t("summary.tag_add_placeholder")
|
||||
: t("summary.tag_first_placeholder")}
|
||||
bind:value={tagDraft}
|
||||
oninput={onTagInput}
|
||||
onkeydown={onTagInputKeydown}
|
||||
@@ -580,17 +595,17 @@
|
||||
/>
|
||||
</div>
|
||||
<datalist id="known-tags">
|
||||
{#each meetings.allTags as t (t)}
|
||||
<option value={t}></option>
|
||||
{#each meetings.allTags as tag (tag)}
|
||||
<option value={tag}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<div class="actions">
|
||||
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{generatingTags ? "Generating…" : "Generate tags"}
|
||||
{generatingTags ? t("summary.generating") : t("summary.generate_tags")}
|
||||
</button>
|
||||
<button class="link" onclick={saveTags} disabled={savingTags}>
|
||||
{savingTags ? "Saving…" : "Save tags"}
|
||||
{savingTags ? t("summary.saving") : t("summary.save_tags")}
|
||||
</button>
|
||||
</div>
|
||||
{#if tagGenError}
|
||||
@@ -598,18 +613,18 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
|
||||
<h3><Sparkles size={14} aria-hidden="true" /> {t("summary.summary_heading")}</h3>
|
||||
|
||||
<div class="provider-row">
|
||||
<label class="provider-select">
|
||||
<span class="sr-only">AI provider</span>
|
||||
<span class="sr-only">{t("summary.ai_provider")}</span>
|
||||
<select
|
||||
value={llmStatus?.provider ?? settings.settings.llm_provider}
|
||||
onchange={onProviderChange}
|
||||
disabled={switchingProvider}
|
||||
>
|
||||
{#each Object.entries(PROVIDER_LABELS) as [id, label] (id)}
|
||||
<option value={id}>{label}</option>
|
||||
{#each PROVIDER_IDS as id (id)}
|
||||
<option value={id}>{providerLabel(id)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
@@ -619,7 +634,7 @@
|
||||
size={12}
|
||||
aria-hidden="true"
|
||||
/>{/if}
|
||||
{llmStatus.isLocal ? "local" : "leaves this device"}
|
||||
{llmStatus.isLocal ? t("summary.local") : t("summary.leaves_device")}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -632,9 +647,9 @@
|
||||
{/if}
|
||||
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to generate a summary.</p>
|
||||
<p class="muted">{t("summary.select_generate")}</p>
|
||||
{:else if meetings.summarizingId === meetings.selected.id}
|
||||
<p class="muted">Generating…</p>
|
||||
<p class="muted">{t("summary.generating")}</p>
|
||||
{#if meetings.summaryStream}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="summary-md">{@html renderMarkdown(meetings.summaryStream)}</div>
|
||||
@@ -643,22 +658,22 @@
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="summary-md">{@html renderMarkdown(meetings.selected.summary.summary_md)}</div>
|
||||
{#if meetings.selected.summary.decisions.length > 0}
|
||||
<h4>Decisions</h4>
|
||||
<h4>{t("summary.decisions_heading")}</h4>
|
||||
<ul class="decisions">
|
||||
{#each meetings.selected.summary.decisions as d, i (i)}
|
||||
<li>{d}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="link" onclick={onGenerateClick}>Regenerate</button>
|
||||
<button class="link" onclick={onGenerateClick}>{t("summary.regenerate")}</button>
|
||||
{:else}
|
||||
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
|
||||
<p class="muted">{t("summary.generated_hint")}</p>
|
||||
{#if llmStatus && llmStatus.provider === "off"}
|
||||
<p class="muted small">No AI provider configured — enable one in Settings.</p>
|
||||
<p class="muted small">{t("summary.no_provider")}</p>
|
||||
{:else}
|
||||
<button class="primary" onclick={onGenerateClick}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
Generate summary
|
||||
{t("summary.generate_summary")}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -666,24 +681,21 @@
|
||||
<p class="error">{meetings.summaryError}</p>
|
||||
{/if}
|
||||
|
||||
<h3><FileText size={14} aria-hidden="true" /> Feature briefs</h3>
|
||||
<h3><FileText size={14} aria-hidden="true" /> {t("summary.briefs_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to create or view feature briefs.</p>
|
||||
<p class="muted">{t("summary.briefs_select")}</p>
|
||||
{:else}
|
||||
<p class="muted small">
|
||||
Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a
|
||||
coding agent, or serve it over the MCP server once that's on.
|
||||
</p>
|
||||
<p class="muted small">{t("summary.briefs_desc")}</p>
|
||||
<div class="brief-create">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Target repo (optional), e.g. acme/reporting-web"
|
||||
placeholder={t("summary.brief_repo_placeholder")}
|
||||
bind:value={briefTargetRepo}
|
||||
disabled={creatingBrief}
|
||||
/>
|
||||
<button class="primary" onclick={createBrief} disabled={creatingBrief}>
|
||||
<Sparkles size={14} aria-hidden="true" />
|
||||
{creatingBrief ? "Distilling…" : "Create feature brief"}
|
||||
{creatingBrief ? t("summary.distilling") : t("summary.create_brief")}
|
||||
</button>
|
||||
</div>
|
||||
{#if briefError}
|
||||
@@ -698,7 +710,7 @@
|
||||
{b.title}
|
||||
{#if b.target_repo}<span class="muted small">{b.target_repo}</span>{/if}
|
||||
</button>
|
||||
<label class="expose" title="Available when the MCP server is on">
|
||||
<label class="expose" title={t("summary.brief_mcp_title")}>
|
||||
<input type="checkbox" checked={b.exposed} onchange={() => toggleBriefExposed(b)} />
|
||||
<span class="muted small">MCP</span>
|
||||
</label>
|
||||
@@ -712,24 +724,24 @@
|
||||
<div class="brief-actions">
|
||||
<button class="link" onclick={copyBriefMarkdown}>
|
||||
{#if copiedFormat === "md"}
|
||||
<Check size={13} aria-hidden="true" /> Copied
|
||||
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
|
||||
{:else}
|
||||
<Copy size={13} aria-hidden="true" /> Copy as Markdown
|
||||
<Copy size={13} aria-hidden="true" /> {t("summary.copy_md")}
|
||||
{/if}
|
||||
</button>
|
||||
<button class="link" onclick={copyBriefJson}>
|
||||
{#if copiedFormat === "json"}
|
||||
<Check size={13} aria-hidden="true" /> Copied
|
||||
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
|
||||
{:else}
|
||||
<Copy size={13} aria-hidden="true" /> Copy as JSON
|
||||
<Copy size={13} aria-hidden="true" /> {t("summary.copy_json")}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<h4>Problem</h4>
|
||||
<h4>{t("summary.brief_problem")}</h4>
|
||||
<p class="brief-text">{selectedBrief.problem}</p>
|
||||
<h4>Desired outcome</h4>
|
||||
<h4>{t("summary.brief_outcome")}</h4>
|
||||
<p class="brief-text">{selectedBrief.desired_outcome}</p>
|
||||
<h4>Acceptance criteria</h4>
|
||||
<h4>{t("summary.brief_criteria")}</h4>
|
||||
{#if selectedBrief.acceptance_criteria.length}
|
||||
<ul class="decisions">
|
||||
{#each selectedBrief.acceptance_criteria as c, i (i)}
|
||||
@@ -737,10 +749,10 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted small">None captured.</p>
|
||||
<p class="muted small">{t("summary.brief_none")}</p>
|
||||
{/if}
|
||||
{#if selectedBrief.context_excerpts.length}
|
||||
<h4>Context</h4>
|
||||
<h4>{t("summary.brief_context")}</h4>
|
||||
<ul class="excerpts">
|
||||
{#each selectedBrief.context_excerpts as e, i (i)}
|
||||
<li><span class="speaker">{e.speaker}:</span> "{e.text}"</li>
|
||||
@@ -751,14 +763,12 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> Action items</h3>
|
||||
<h3><ListTodo size={14} aria-hidden="true" /> {t("summary.action_items_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to see its action items.</p>
|
||||
<p class="muted">{t("summary.ai_select")}</p>
|
||||
{:else}
|
||||
{#if editableItems.length === 0}
|
||||
<p class="muted">
|
||||
None yet — add one below, or generate a summary to extract them automatically.
|
||||
</p>
|
||||
<p class="muted">{t("summary.ai_empty")}</p>
|
||||
{:else}
|
||||
<ul class="action-items">
|
||||
{#each editableItems as item, i (i)}
|
||||
@@ -766,38 +776,38 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={item.confirmed}
|
||||
aria-label="Confirmed"
|
||||
title="Confirmed"
|
||||
aria-label={t("summary.confirmed")}
|
||||
title={t("summary.confirmed")}
|
||||
/>
|
||||
<input
|
||||
class="ai-text"
|
||||
bind:value={item.text}
|
||||
placeholder="Action item…"
|
||||
aria-label="Action item text"
|
||||
placeholder={t("summary.ai_text_placeholder")}
|
||||
aria-label={t("summary.ai_text_aria")}
|
||||
/>
|
||||
<input
|
||||
class="ai-owner"
|
||||
value={item.owner ?? ""}
|
||||
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
|
||||
placeholder="Owner"
|
||||
aria-label="Owner"
|
||||
placeholder={t("summary.owner")}
|
||||
aria-label={t("summary.owner")}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
class="due-date"
|
||||
aria-label="Due date"
|
||||
aria-label={t("summary.due_date")}
|
||||
value={dueDateInput(item.due_at)}
|
||||
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<label class="remind" title="Schedule a local reminder for this due date">
|
||||
<label class="remind" title={t("summary.reminder_title")}>
|
||||
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
|
||||
<Bell size={14} aria-hidden="true" />
|
||||
</label>
|
||||
<button
|
||||
class="ai-del"
|
||||
onclick={() => removeActionItem(i)}
|
||||
title="Delete this action item"
|
||||
aria-label="Delete action item"
|
||||
title={t("summary.ai_delete_title")}
|
||||
aria-label={t("summary.ai_delete_aria")}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -807,47 +817,57 @@
|
||||
{/if}
|
||||
<div class="ai-actions">
|
||||
<button class="link" onclick={addActionItem}>
|
||||
<Plus size={14} aria-hidden="true" /> Add action item
|
||||
<Plus size={14} aria-hidden="true" />
|
||||
{t("summary.add_action_item")}
|
||||
</button>
|
||||
<button class="primary" onclick={saveActionItems} disabled={savingItems}>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{savingItems ? "Saving…" : "Save action items"}
|
||||
{savingItems ? t("summary.saving") : t("summary.save_action_items")}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> Calendar event</h3>
|
||||
<h3><CalendarDays size={14} aria-hidden="true" /> {t("summary.calendar_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to link it to a calendar event.</p>
|
||||
<p class="muted">{t("summary.cal_select")}</p>
|
||||
{:else}
|
||||
<div class="row">
|
||||
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={eventLinkSearch}
|
||||
placeholder={t("summary.event_search_placeholder")}
|
||||
/>
|
||||
<input type="date" bind:value={eventLinkDateFilter} />
|
||||
</div>
|
||||
<label class="pick"
|
||||
>Linked event
|
||||
>{t("summary.linked_event")}
|
||||
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
|
||||
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
|
||||
<option value="" disabled
|
||||
>{eventDetail ? t("summary.change_event") : t("summary.link_event")}</option
|
||||
>
|
||||
{#each filteredLinkEvents as ev (ev.id)}
|
||||
<option value={ev.id}
|
||||
>{ev.subject ?? "(untitled)"} — {formatEventTime(ev.starts_at)}</option
|
||||
>{ev.subject ?? t("summary.untitled_event")} — {formatEventTime(ev.starts_at)}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if calendar.events.length === 0}
|
||||
<p class="muted small">
|
||||
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
|
||||
{t("summary.no_events_1")} <code>.pst</code>
|
||||
{t("summary.no_events_2")}
|
||||
</p>
|
||||
{:else if filteredLinkEvents.length === 0}
|
||||
<p class="muted small">No events match this search/date — try clearing one.</p>
|
||||
<p class="muted small">{t("summary.no_events_match")}</p>
|
||||
{/if}
|
||||
|
||||
{#if eventDetail}
|
||||
<div class="event-card">
|
||||
<div class="event-title">{eventDetail.event.subject ?? "(untitled)"}</div>
|
||||
<div class="event-title">{eventDetail.event.subject ?? t("summary.untitled_event")}</div>
|
||||
{#if eventDetail.event.organizer}
|
||||
<div class="muted small">Organizer: {eventDetail.event.organizer}</div>
|
||||
<div class="muted small">
|
||||
{t("summary.organizer_label", { name: eventDetail.event.organizer })}
|
||||
</div>
|
||||
{/if}
|
||||
{#if eventDetail.event.starts_at}
|
||||
<div class="muted small">{formatEventTime(eventDetail.event.starts_at)}</div>
|
||||
@@ -856,9 +876,9 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h3><Users size={14} aria-hidden="true" /> Participants</h3>
|
||||
<h3><Users size={14} aria-hidden="true" /> {t("summary.participants_heading")}</h3>
|
||||
{#if !eventDetail || eventDetail.participants.length === 0}
|
||||
<p class="muted">Populated from the linked calendar event.</p>
|
||||
<p class="muted">{t("summary.participants_hint")}</p>
|
||||
{:else}
|
||||
<ul class="participants">
|
||||
{#each eventDetail.participants as p (p.id)}
|
||||
@@ -870,11 +890,11 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Speakers</h3>
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.speakers_heading")}</h3>
|
||||
{#if !meetings.selected}
|
||||
<p class="muted">Select a meeting to name its speakers.</p>
|
||||
<p class="muted">{t("summary.speakers_select")}</p>
|
||||
{:else if meetings.selected.speakers.length === 0}
|
||||
<p class="muted">No speakers detected yet.</p>
|
||||
<p class="muted">{t("summary.no_speakers")}</p>
|
||||
{:else}
|
||||
<ul class="speakers">
|
||||
{#each meetings.selected.speakers as s (s.label)}
|
||||
@@ -883,23 +903,26 @@
|
||||
{#if addingNameFor === s.label}
|
||||
<input
|
||||
class="grow"
|
||||
placeholder="Speaker name"
|
||||
placeholder={t("summary.speaker_name_placeholder")}
|
||||
bind:value={newNameDraft}
|
||||
onkeydown={(e) => e.key === "Enter" && confirmNewName(s.label)}
|
||||
/>
|
||||
<button class="link" onclick={() => confirmNewName(s.label)}>Save</button>
|
||||
<button class="link" onclick={() => (addingNameFor = null)}>Cancel</button>
|
||||
<button class="link" onclick={() => confirmNewName(s.label)}>{t("summary.save")}</button
|
||||
>
|
||||
<button class="link" onclick={() => (addingNameFor = null)}
|
||||
>{t("summary.cancel")}</button
|
||||
>
|
||||
{:else}
|
||||
<select
|
||||
class="grow"
|
||||
value={s.participant_id ?? ""}
|
||||
onchange={(e) => onPickSpeakerOption(e, s.label)}
|
||||
>
|
||||
<option value="" disabled>{s.display_name ?? "Name this speaker…"}</option>
|
||||
<option value="" disabled>{s.display_name ?? t("summary.name_speaker")}</option>
|
||||
{#each eventDetail?.participants ?? [] as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
<option value={NEW_NAME}>+ Add new name…</option>
|
||||
<option value={NEW_NAME}>{t("summary.add_new_name")}</option>
|
||||
</select>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
Reference in new issue
Block a user