detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
36 files changed
+8820
-98
No files matched your search
@@ -5,6 +5,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
export interface UpdateCheckResultItem {
|
||||
containerId: string;
|
||||
@@ -12,6 +13,12 @@
|
||||
imageName: string;
|
||||
}
|
||||
|
||||
/** A container that has a newer VERSION tag (semver) — independent of digest updates. */
|
||||
export interface NewerVersionItem {
|
||||
containerId: string;
|
||||
newerVersion: NewerVersion;
|
||||
}
|
||||
|
||||
export interface FailedCheckItem {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
@@ -24,7 +31,11 @@
|
||||
/** When true and no check is running, the button reflects the "updates found" (green) state — e.g. from persisted pending updates loaded on mount. */
|
||||
hasPendingUpdates?: boolean;
|
||||
/** Called when a check finishes with the containers that have updates and any failures. */
|
||||
onComplete?: (result: { withUpdates: UpdateCheckResultItem[]; failed: FailedCheckItem[] }) => void;
|
||||
onComplete?: (result: {
|
||||
withUpdates: UpdateCheckResultItem[];
|
||||
failed: FailedCheckItem[];
|
||||
newerVersions: NewerVersionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
let { envId, hasPendingUpdates = false, onComplete }: Props = $props();
|
||||
@@ -119,8 +130,19 @@
|
||||
const failed: FailedCheckItem[] = data.results
|
||||
.filter((r: any) => r.error && !r.hasUpdate)
|
||||
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName, error: r.error }));
|
||||
// Semver suggestions are independent of digest updates: a container can be
|
||||
// digest-current yet run an outdated version tag.
|
||||
const newerVersions: NewerVersionItem[] = data.results
|
||||
.filter((r: any) => r.newerVersion)
|
||||
.map((r: any) => ({ containerId: r.containerId, newerVersion: r.newerVersion }));
|
||||
|
||||
if (withUpdates.length === 0) {
|
||||
// A newer version tag is a real result even when no digest update exists,
|
||||
// so it counts toward the "found" state and the summary message.
|
||||
const semverNote = newerVersions.length > 0
|
||||
? `${newerVersions.length} newer version tag${newerVersions.length !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
if (withUpdates.length === 0 && newerVersions.length === 0) {
|
||||
// Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019)
|
||||
status = 'none';
|
||||
if (failed.length > 0) {
|
||||
@@ -130,14 +152,19 @@
|
||||
}
|
||||
} else {
|
||||
status = 'found';
|
||||
const parts = [
|
||||
withUpdates.length > 0 ? `${withUpdates.length} update${withUpdates.length !== 1 ? 's' : ''} available` : '',
|
||||
semverNote
|
||||
].filter(Boolean);
|
||||
const summary = parts.join(', ');
|
||||
if (failed.length > 0) {
|
||||
showFailedChecksToast(failed, `${withUpdates.length} update(s) available`);
|
||||
showFailedChecksToast(failed, summary);
|
||||
} else {
|
||||
toast.info(`${withUpdates.length} update(s) available`);
|
||||
toast.info(summary);
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.({ withUpdates, failed });
|
||||
onComplete?.({ withUpdates, failed, newerVersions });
|
||||
} catch {
|
||||
failError();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Tag, Info } from 'lucide-svelte';
|
||||
|
||||
type Bump = 'patch' | 'minor' | 'major';
|
||||
|
||||
interface Props {
|
||||
/** Master toggle: also detect newer version tags for pinned images. */
|
||||
enabled: boolean;
|
||||
/** Cap the surfaced bump. */
|
||||
maxBump: Bump;
|
||||
/** Require the same tag suffix (flavor) as the running tag. */
|
||||
matchFlavor: boolean;
|
||||
/** Consider -rc/-beta prerelease tags. */
|
||||
includePrerelease: boolean;
|
||||
/**
|
||||
* When set, the current tag is floating (latest/stable/sha) so there is no
|
||||
* version to compare — the toggle is disabled and this tag is shown in the hint.
|
||||
* Container scope passes it; env scope leaves it undefined.
|
||||
*/
|
||||
floatingTag?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
enabled = $bindable(),
|
||||
maxBump = $bindable(),
|
||||
matchFlavor = $bindable(),
|
||||
includePrerelease = $bindable(),
|
||||
floatingTag
|
||||
}: Props = $props();
|
||||
|
||||
const isFloating = $derived(!!floatingTag);
|
||||
const bumps: Bump[] = ['patch', 'minor', 'major'];
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-2" class:opacity-55={isFloating}>
|
||||
<Tag class="w-4 h-4 text-green-500 glow-green mt-0.5 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<Label>Check for newer version tags</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
For a container pinned to a version tag (e.g. <code>16.2</code>, <code>v3.0</code>), also detect
|
||||
when a newer version is published. Advisory only.
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill bind:checked={enabled} disabled={isFloating} />
|
||||
</div>
|
||||
|
||||
{#if isFloating}
|
||||
<div class="ml-6 flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<Info class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span><code>{floatingTag}</code> is a floating tag — pin a version tag (e.g. <code>1.26</code>) to enable newer-version detection.</span>
|
||||
</div>
|
||||
{:else if enabled}
|
||||
<!-- Indented under the header so it reads as "all this belongs to the toggle
|
||||
above"; the boxes inside are full-width of THIS container so they line up. -->
|
||||
<div class="ml-6 space-y-4">
|
||||
<!-- max bump -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<Label class="flex-1">Which updates to show</Label>
|
||||
<div class="inline-flex shrink-0 gap-0.5 rounded-lg border border-border bg-muted/60 p-0.5">
|
||||
{#each bumps as bump}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (maxBump = bump)}
|
||||
class="rounded-md px-3.5 py-1.5 text-xs font-semibold capitalize transition-colors
|
||||
{maxBump === bump
|
||||
? 'bg-green-500/15 text-green-500 shadow-[inset_0_0_0_1px_rgba(34,197,94,0.3)]'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{bump}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border bg-muted/40 p-2.5 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
Running <code>1.4.2</code>, you'll be told about:<br />
|
||||
<span class="font-bold text-foreground">Patch</span> bug-fix only → <code>1.4.3</code><br />
|
||||
<span class="font-bold text-foreground">Minor</span> new features too → <code>1.4.3</code>, <code>1.5.0</code><br />
|
||||
<span class="font-bold text-foreground">Major</span> everything, incl. breaking → <code>1.4.3</code>, <code>1.5.0</code>, <code>2.0.0</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- match flavor -->
|
||||
<div class="space-y-2 border-t border-border/60 pt-3">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<Label>Match the tag flavor</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
Only suggest tags with the same suffix as the one you run — the flavor, like
|
||||
<code>-alpine</code> or <code>-ls123</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0"><TogglePill bind:checked={matchFlavor} /></div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border bg-muted/40 p-2.5 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
<span class="font-bold text-foreground">On</span> <code>1.2-alpine</code> → <code>1.5-alpine</code>, never bare <code>1.5</code><br />
|
||||
<span class="font-bold text-foreground">Off</span> any newer version, even a different flavor — noisier
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- include prereleases -->
|
||||
<div class="flex items-start gap-4 border-t border-border/60 pt-3">
|
||||
<div class="flex-1">
|
||||
<Label>Include prereleases</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
Consider <code>-rc</code> / <code>-beta</code> tags. Off keeps a stable deployment on
|
||||
stable releases.
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0"><TogglePill bind:checked={includePrerelease} /></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if enabled && !isFloating}
|
||||
<div class="ml-6 flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<Info class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>A newer version shows as a badge on the container — it is <strong class="text-foreground font-semibold">never auto-applied</strong>. Bump the tag in your compose yourself.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
code {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: hsl(var(--muted));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { Tag } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** The newer-version suggestion for this container (target tag + how many were skipped). */
|
||||
newerVersion: NewerVersion;
|
||||
/** Called when the badge is clicked — opens the release-notes modal. */
|
||||
onclick?: () => void;
|
||||
/** Compact icon-only marker for the image icon-row (default), or a full pill with the tag. */
|
||||
variant?: 'icon' | 'pill';
|
||||
}
|
||||
|
||||
let { newerVersion, onclick, variant = 'icon' }: Props = $props();
|
||||
|
||||
// How many versions behind the running tag is (skipped is inclusive of the target).
|
||||
const behind = $derived(newerVersion.skipped.length);
|
||||
const bumpColor = $derived(
|
||||
newerVersion.bump === 'major'
|
||||
? 'text-red-400'
|
||||
: newerVersion.bump === 'minor'
|
||||
? 'text-amber-400'
|
||||
: 'text-indigo-400'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onclick?.();
|
||||
}}
|
||||
class="shrink-0 inline-flex items-center gap-1 self-center text-amber-500 hover:text-amber-400 transition-colors cursor-pointer
|
||||
{variant === 'pill'
|
||||
? 'rounded-full bg-amber-500/10 hover:bg-amber-500/20 px-1.5 py-0.5 text-2xs font-medium'
|
||||
: ''}"
|
||||
>
|
||||
<Tag class="w-3 h-3 shrink-0" />
|
||||
{#if variant === 'pill'}
|
||||
<span>{newerVersion.tag}</span>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right" class="w-64 p-3">
|
||||
<div class="space-y-1.5">
|
||||
<p class="font-medium text-sm flex items-center gap-1.5">
|
||||
<Tag class="w-4 h-4 text-amber-500 shrink-0" />
|
||||
Newer version available
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-muted-foreground">Move to</span>
|
||||
<code class="mx-0.5 rounded bg-muted px-1 py-0.5 font-mono">{newerVersion.tag}</code>
|
||||
<span class="font-semibold uppercase {bumpColor}">{newerVersion.bump}</span>
|
||||
</p>
|
||||
{#if behind > 1}
|
||||
<p class="text-xs text-muted-foreground">{behind} versions ahead of the tag you run.</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">Click for release notes. Never auto-applied.</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Box, ArrowRight, ExternalLink, Copy, Check, RefreshCw, ChevronDown, BookOpen } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { renderMarkdown } from '$lib/utils/markdown';
|
||||
import { formatDate } from '$lib/stores/settings';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
// Minimal shape shared by ContainerInfo and StackContainer - all the modal needs.
|
||||
type ModalContainer = { id: string; name: string; image: string };
|
||||
|
||||
interface ReleaseNote {
|
||||
version: string;
|
||||
name: string;
|
||||
githubTag: string;
|
||||
body: string;
|
||||
publishedAt: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The container this modal is about; null closes the modal. */
|
||||
container: ModalContainer | null;
|
||||
/** The newer-version suggestion (target tag + skipped versions). */
|
||||
newerVersion: NewerVersion | null;
|
||||
envId: number | null;
|
||||
}
|
||||
|
||||
let { container = $bindable(), newerVersion, envId }: Props = $props();
|
||||
|
||||
const open = $derived(!!container && !!newerVersion);
|
||||
|
||||
// The current tag = the part after the last ':' of the image reference.
|
||||
const currentTag = $derived.by(() => {
|
||||
const img = container?.image ?? '';
|
||||
const colon = img.lastIndexOf(':');
|
||||
return colon > img.lastIndexOf('/') ? img.slice(colon + 1) : 'latest';
|
||||
});
|
||||
|
||||
const bumpColor = $derived(
|
||||
newerVersion?.bump === 'major'
|
||||
? 'text-red-400 border-red-400/40 bg-red-400/10'
|
||||
: newerVersion?.bump === 'minor'
|
||||
? 'text-amber-400 border-amber-400/40 bg-amber-400/10'
|
||||
: 'text-amber-400 border-amber-400/40 bg-amber-400/10'
|
||||
);
|
||||
|
||||
// skipped is ordered current-exclusive .. target-inclusive.
|
||||
const versionPath = $derived(newerVersion?.skipped ?? []);
|
||||
|
||||
let loading = $state(false);
|
||||
let notes = $state<ReleaseNote[]>([]);
|
||||
let changelogUrl = $state<string | null>(null);
|
||||
let source = $state<string | null>(null);
|
||||
let loadedFor = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
// Fetch notes when the modal opens for a (container, target) pair we haven't loaded.
|
||||
$effect(() => {
|
||||
if (!open || !container || !newerVersion) return;
|
||||
const key = `${container.id}:${newerVersion.tag}`;
|
||||
if (loadedFor === key) return;
|
||||
loadedFor = key;
|
||||
void loadNotes(container.id, versionPath);
|
||||
});
|
||||
|
||||
async function loadNotes(containerId: string, versions: string[]) {
|
||||
loading = true;
|
||||
notes = [];
|
||||
changelogUrl = null;
|
||||
source = null;
|
||||
try {
|
||||
const qs = `versions=${encodeURIComponent(versions.join(','))}`;
|
||||
const res = await fetch(
|
||||
appendEnvParam(`/api/containers/${containerId}/version-notes?${qs}`, envId)
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notes = data.notes ?? [];
|
||||
changelogUrl = data.changelogUrl ?? null;
|
||||
source = data.source ?? null;
|
||||
}
|
||||
} catch {
|
||||
// leave notes empty -> the "no notes" fallback renders
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTag() {
|
||||
if (!container || !newerVersion) return;
|
||||
const img = container.image;
|
||||
const base = img.slice(0, img.lastIndexOf(':') > img.lastIndexOf('/') ? img.lastIndexOf(':') : img.length);
|
||||
const ok = await copyToClipboard(`${base}:${newerVersion.tag}`);
|
||||
if (ok) {
|
||||
copied = true;
|
||||
toast.success('New image tag copied');
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
container = null;
|
||||
}
|
||||
|
||||
// Note for a given version path entry, if GitHub had one.
|
||||
function noteFor(version: string): ReleaseNote | undefined {
|
||||
return notes.find((n) => n.version === version);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={(v) => { if (!v) close(); }}>
|
||||
<Dialog.Content class="max-w-3xl max-h-[90vh] flex flex-col !animate-none">
|
||||
{#if container && newerVersion}
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2.5 flex-wrap pr-8">
|
||||
<Box class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<span class="font-semibold">{container.name}</span>
|
||||
<span class="flex items-center gap-2 font-mono text-sm">
|
||||
<span class="text-muted-foreground font-normal">{currentTag}</span>
|
||||
<ArrowRight class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span class="font-semibold text-amber-400">{newerVersion.tag}</span>
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
<span class="rounded-full border px-2 py-0.5 text-2xs font-semibold uppercase {bumpColor}">
|
||||
{newerVersion.bump}
|
||||
</span>
|
||||
{#if versionPath.length > 1}
|
||||
<span class="text-xs font-normal text-muted-foreground">{versionPath.length} versions ahead</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Advisory only. Dockhand never changes the tag in your compose - bump it yourself.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Version path -->
|
||||
{#if versionPath.length > 1}
|
||||
<div class="flex items-center gap-1.5 flex-wrap text-xs px-1">
|
||||
<span class="text-muted-foreground">Path:</span>
|
||||
<span class="font-mono text-muted-foreground">{currentTag}</span>
|
||||
{#each versionPath as v, i}
|
||||
<ArrowRight class="w-3 h-3 text-muted-foreground/60 shrink-0" />
|
||||
<span class="font-mono {i === versionPath.length - 1 ? 'text-amber-400 font-semibold' : 'text-foreground'}">{v}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Release notes -->
|
||||
<div class="flex-1 overflow-auto pr-1 space-y-2 min-h-[120px]">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<RefreshCw class="w-5 h-5 animate-spin" />
|
||||
<span class="ml-2 text-sm">Loading release notes...</span>
|
||||
</div>
|
||||
{:else if notes.length > 0}
|
||||
{#each versionPath.slice().reverse() as version}
|
||||
{@const note = noteFor(version)}
|
||||
{#if note}
|
||||
<details class="group rounded-md border border-border bg-muted/30 [&_summary::-webkit-details-marker]:hidden" open={version === newerVersion.tag || versionPath.length <= 3}>
|
||||
<summary class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 transition-colors rounded-md list-none">
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground shrink-0 transition-transform group-open:rotate-0 -rotate-90" />
|
||||
<span class="font-mono text-sm font-semibold">{note.name || note.githubTag}</span>
|
||||
{#if note.publishedAt}
|
||||
<span class="ml-auto text-xs text-muted-foreground">{formatDate(note.publishedAt)}</span>
|
||||
{/if}
|
||||
<a
|
||||
href={note.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="text-muted-foreground hover:text-foreground shrink-0 {note.publishedAt ? '' : 'ml-auto'}"
|
||||
title="Open on GitHub"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</summary>
|
||||
<div class="markdown-body border-t border-border/60 px-3 py-3 text-sm">
|
||||
{@html renderMarkdown(note.body)}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Fallback: no per-version notes found -->
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
||||
<BookOpen class="w-8 h-8 text-muted-foreground/50" />
|
||||
<p class="text-sm text-muted-foreground max-w-sm">
|
||||
{#if changelogUrl}
|
||||
Release notes for these versions aren't available inline.
|
||||
{:else}
|
||||
This image doesn't publish release notes Dockhand can read
|
||||
(no <code class="text-xs">org.opencontainers.image.source</code> label pointing at a GitHub or Gitea/Forgejo repo).
|
||||
{/if}
|
||||
</p>
|
||||
{#if changelogUrl}
|
||||
<Button variant="outline" size="sm" href={changelogUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" /> View changelog
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex-row items-center gap-2 sm:justify-between">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{#if source}
|
||||
Notes from <code class="text-xs">{source}</code>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={copyTag}>
|
||||
{#if copied}
|
||||
<Check class="w-3.5 h-3.5 mr-1.5 text-green-500" /> Copied
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5 mr-1.5" /> Copy new tag
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={close}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
/* Scoped typography for rendered release-note markdown. */
|
||||
.markdown-body :global(h1),
|
||||
.markdown-body :global(h2),
|
||||
.markdown-body :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.markdown-body :global(h1) { font-size: 1.05rem; }
|
||||
.markdown-body :global(h2) { font-size: 1rem; }
|
||||
.markdown-body :global(h3) { font-size: 0.9rem; }
|
||||
.markdown-body :global(p) { margin: 0.4rem 0; }
|
||||
.markdown-body :global(ul),
|
||||
.markdown-body :global(ol) { margin: 0.4rem 0; padding-left: 1.4rem; }
|
||||
.markdown-body :global(li) { margin: 0.15rem 0; }
|
||||
.markdown-body :global(ul) { list-style: disc; }
|
||||
.markdown-body :global(ol) { list-style: decimal; }
|
||||
.markdown-body :global(a) { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
.markdown-body :global(code) {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
.markdown-body :global(pre) {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-body :global(pre code) { background: none; padding: 0; }
|
||||
.markdown-body :global(blockquote) {
|
||||
border-left: 3px solid hsl(var(--border));
|
||||
padding-left: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-body :global(hr) { border: none; border-top: 1px solid hsl(var(--border)); margin: 0.75rem 0; }
|
||||
.markdown-body :global(img) { max-width: 100%; }
|
||||
</style>
|
||||
Reference in new issue
Block a user