mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-11-11 14:20:38 +08:00
182 lines
4.9 KiB
Svelte
182 lines
4.9 KiB
Svelte
<script lang="ts">
|
|
import type { Config } from "@bindings/config/Config";
|
|
import type { ComponentProps } from "svelte";
|
|
|
|
import debounce from "lodash/debounce";
|
|
|
|
import { writeToClipboard } from "@app/lib/invoke";
|
|
import {
|
|
defaultShareAction,
|
|
type ShareAction,
|
|
shareAction,
|
|
} from "@app/lib/shareAction.svelte";
|
|
import { explorerHost, explorerUrl } from "@app/lib/utils";
|
|
|
|
import Button from "@app/components/Button.svelte";
|
|
import DropdownList from "@app/components/DropdownList.svelte";
|
|
import DropdownListItem from "@app/components/DropdownListItem.svelte";
|
|
import Icon from "@app/components/Icon.svelte";
|
|
import Popover, { closeFocused } from "@app/components/Popover.svelte";
|
|
|
|
type IconName = ComponentProps<typeof Icon>["name"];
|
|
|
|
interface Props {
|
|
explorerPath: string;
|
|
id: string;
|
|
// The noun the id represents, e.g. "issue", so "copy id" reads "Copy issue ID".
|
|
idLabel: string;
|
|
config: Config;
|
|
styleHeight?: ComponentProps<typeof Button>["styleHeight"];
|
|
variant?: ComponentProps<typeof Button>["variant"];
|
|
}
|
|
|
|
const {
|
|
explorerPath,
|
|
id,
|
|
idLabel,
|
|
config,
|
|
styleHeight = "2rem",
|
|
variant = "ghost",
|
|
}: Props = $props();
|
|
|
|
const url = $derived(explorerUrl(explorerPath, config));
|
|
const host = $derived(explorerHost(config));
|
|
|
|
const actions = $derived<
|
|
{ kind: ShareAction; icon: IconName; label: string; title: string }[]
|
|
>([
|
|
{
|
|
kind: "open",
|
|
icon: "open-external",
|
|
label: "Open on web",
|
|
title: `Open on ${host}`,
|
|
},
|
|
{
|
|
kind: "copyLink",
|
|
icon: "link",
|
|
label: "Copy link",
|
|
title: `Copy link to ${host}`,
|
|
},
|
|
{
|
|
kind: "copyId",
|
|
icon: "copy",
|
|
label: "Copy ID",
|
|
title: `Copy ${idLabel} ID`,
|
|
},
|
|
]);
|
|
|
|
// The lookup always matches; the fallback only narrows away `undefined`.
|
|
const selected = $derived(
|
|
actions.find(a => a.kind === shareAction.value) ??
|
|
actions.find(a => a.kind === defaultShareAction)!,
|
|
);
|
|
|
|
let popoverExpanded: boolean = $state(false);
|
|
|
|
let copied: boolean = $state(false);
|
|
const restore = debounce(() => {
|
|
copied = false;
|
|
}, 1000);
|
|
|
|
let anchorEl: HTMLAnchorElement | undefined = $state();
|
|
|
|
async function run(kind: ShareAction) {
|
|
if (kind === "open") {
|
|
anchorEl?.click();
|
|
return;
|
|
}
|
|
try {
|
|
await writeToClipboard(kind === "copyLink" ? url : id);
|
|
copied = true;
|
|
restore();
|
|
} catch {
|
|
// Ignore clipboard failures; there's nothing useful to show the user.
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
/* The 1px gap reveals the container's background as a divider between the two
|
|
buttons, which relies on the buttons having an opaque fill (ghost). */
|
|
.split {
|
|
display: inline-flex;
|
|
gap: 1px;
|
|
background-color: var(--color-border-subtle);
|
|
border-radius: var(--border-radius-sm);
|
|
}
|
|
/* Naked buttons are transparent, so drop the divider background that would
|
|
otherwise show through and read as a grey fill. */
|
|
.split.naked {
|
|
background-color: transparent;
|
|
gap: 0;
|
|
}
|
|
</style>
|
|
|
|
<!-- Hidden anchor used for the "open" action so Tauri's shell plugin opens the
|
|
link in the system browser, matching how ExternalLink behaves. -->
|
|
<a
|
|
bind:this={anchorEl}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
style:display="none"
|
|
tabindex="-1"
|
|
aria-hidden="true">
|
|
</a>
|
|
|
|
<div class="split" class:naked={variant === "naked"}>
|
|
<Button
|
|
{variant}
|
|
{styleHeight}
|
|
flatRight
|
|
stylePadding="0 0.75rem"
|
|
title={selected.title}
|
|
onclick={() => run(selected.kind)}>
|
|
<Icon
|
|
name={copied && selected.kind !== "open" ? "checkmark" : selected.icon} />
|
|
<span class="global-hide-on-medium-desktop-down">{selected.label}</span>
|
|
</Button>
|
|
|
|
<Popover
|
|
popoverPadding="0"
|
|
placement="bottom-end"
|
|
bind:expanded={popoverExpanded}>
|
|
{#snippet toggle(onclick)}
|
|
<Button
|
|
{variant}
|
|
{styleHeight}
|
|
styleWidth={styleHeight}
|
|
flatLeft
|
|
stylePadding="0"
|
|
active={popoverExpanded}
|
|
title="Share options"
|
|
{onclick}>
|
|
<Icon name={popoverExpanded ? "chevron-up" : "chevron-down"} />
|
|
</Button>
|
|
{/snippet}
|
|
{#snippet popover()}
|
|
<div
|
|
style:border="1px solid var(--color-border-subtle)"
|
|
style:border-radius="var(--border-radius-sm)"
|
|
style:background-color="var(--color-surface-canvas)"
|
|
style:padding="0.25rem">
|
|
<DropdownList items={actions}>
|
|
{#snippet item(action)}
|
|
<DropdownListItem
|
|
selected={action.kind === selected.kind}
|
|
styleGap="0.5rem"
|
|
onclick={() => {
|
|
shareAction.value = action.kind;
|
|
closeFocused();
|
|
void run(action.kind);
|
|
}}>
|
|
<Icon name={action.icon} />
|
|
<span>{action.title}</span>
|
|
</DropdownListItem>
|
|
{/snippet}
|
|
</DropdownList>
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
</div>
|